Initial commit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.remember/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.orig
|
||||||
|
*.rej
|
||||||
|
.venv/
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Hermes robot
|
||||||
|
|
||||||
|
Desk robot giving Hermes Agent (on a NixOS or Omarchy host) a camera, microphones, speakers, a
|
||||||
|
pan/tilt head and an oscilloscope-style face. Hardware: Raspberry Pi 4, Adafruit BrainCraft HAT,
|
||||||
|
Arducam UC-346 (OV5647), two MG90S servos on GPIO12/13. Software: ROS 2 Jazzy on Ubuntu 24.04 on
|
||||||
|
the Pi; on the hosts, ROS runs only in podman containers.
|
||||||
|
|
||||||
|
**Start with `HANDOFF.md`.** It is the implementation brief: hardware facts, what exists, the
|
||||||
|
work packages in order, acceptance checks, and the list of unverified assumptions.
|
||||||
|
|
||||||
|
- `pi/` and `ros/` run on the robot; `omarchy/` (to become `host/`) runs on the owner's machines.
|
||||||
|
- Package 0a (platform proof) comes before everything; start with `host/check-pi-platform.sh`.
|
||||||
|
- `docs/face-prototype.html` is the reference for the face renderer; port it, don't ship it.
|
||||||
|
- `enclosure/` holds the OpenSCAD model and STLs. It was built from estimates; refine it from the
|
||||||
|
owner's caliper measurements and test prints before anyone prints the large parts.
|
||||||
|
- `README.md` still describes the earlier Pi Zero build until package 7 rewrites it.
|
||||||
|
- The MCP server uses the MCP Python SDK 2.x (`mcp.server.mcpserver.MCPServer`), not 1.x `FastMCP`.
|
||||||
|
- Ask before rebooting the Pi, upgrading or holding kernel packages, or opening network ports.
|
||||||
+487
@@ -0,0 +1,487 @@
|
|||||||
|
# Hermes robot: Pi 4 + BrainCraft build — implementation handoff
|
||||||
|
|
||||||
|
This document is the brief for finishing the robot. It is written to be worked through by
|
||||||
|
Claude Code with the owner at hand for the physical steps. Read it in full before changing
|
||||||
|
anything; then work the packages in order, since later ones depend on earlier ones.
|
||||||
|
|
||||||
|
**Platform decision:** the robot's software is a **ROS 2 Jazzy** system from V1 onward, running
|
||||||
|
natively on **Ubuntu 24.04** on the Pi, so that the mobile V2 (section 10) grows out of it rather
|
||||||
|
than replacing it. The owner has used ROS extensively and wants it. Package 0a proves the
|
||||||
|
hardware on Ubuntu before anything else is built.
|
||||||
|
|
||||||
|
## 1. What this is
|
||||||
|
|
||||||
|
A small desk robot that acts as the eyes, ears, voice and face of **Hermes Agent** (Nous
|
||||||
|
Research's open-source agent), which runs on the owner's host machine (**NixOS** or **Omarchy**,
|
||||||
|
an Arch + Hyprland setup). The robot does no reasoning. It:
|
||||||
|
|
||||||
|
- serves photos and head movement to Hermes as MCP tools, over a USB-C cable that carries both
|
||||||
|
power and a private network link (Wi-Fi remains as a fallback);
|
||||||
|
- exposes its microphones and speakers to the host as PipeWire audio devices;
|
||||||
|
- shows an animated oscilloscope-style face on its display, reflecting what the agent is doing.
|
||||||
|
|
||||||
|
The repository already contains a working design for an earlier variant (Pi Zero 2 W, separate
|
||||||
|
I²S mic and amp boards, PCA9685 servo board). That code is the starting point. **This document
|
||||||
|
supersedes `README.md` wherever they differ**; `README.md` describes the Zero build and should be
|
||||||
|
rewritten for this hardware at the end (package 7).
|
||||||
|
|
||||||
|
## 2. Hardware actually on hand
|
||||||
|
|
||||||
|
| Part | Details that matter |
|
||||||
|
|---|---|
|
||||||
|
| Raspberry Pi 4 | In a case. **Ubuntu Server 24.04 (arm64) with ROS 2 Jazzy installed natively.** Fallback if package 0a fails: Raspberry Pi OS with ROS in a container. |
|
||||||
|
| Adafruit BrainCraft HAT | Display: 1.54″ ST7789, 240×240, SPI CE0, DC = GPIO25, backlight = GPIO26. Audio: **WM8960** codec at I²C `0x1A`, I²S on GPIO 18/19/20/21, stereo mics (L/R), stereo 8 Ω 1 W speaker JSTs, headphone jack, hardware audio on/off switch. 3-pin JST ports on **GPIO12** and **GPIO13**, with a 3V/5V solder jumper on the back ("3pin Volt"). STEMMA QT (I²C). Fan header on GPIO4. 5-way joystick, one button, three DotStar LEDs. |
|
||||||
|
| Arducam UC-346 | OV5647 5 MP CSI camera, fixed focus, 15-pin ribbon. Natively supported by libcamera. |
|
||||||
|
| Pan/tilt | Two **MG90S** metal-gear servos. Pan turns the whole cabinet on a 6810 bearing turntable (servo in the base); tilt moves only the camera, in a yoke on top. See `enclosure/`. |
|
||||||
|
| Speakers | 8 Ω, ≤1 W, need JST PH 2-pin plugs for the HAT. |
|
||||||
|
| USB-C power/data splitter | GODIYMODULES 30 × 25 mm board, PiKVM-style (ports RPI4 / USB / PWR, φ2.75 mm holes): power from the 5.1 V 3 A supply, data only to the host. Mounted in the base's connector block; a ~30 cm USB-C cable (right-angle at the Pi end) links it to the Pi. |
|
||||||
|
| Host computers | Either a **Framework Desktop (Strix Halo)** or an **ASUS ROG Flow Z13**, each running **NixOS or Omarchy (Arch)**. The robot must work with whichever it's plugged into. Hermes runs natively on the host; **any ROS software on a host runs in a podman container** (owner's rule, so both distros behave the same). |
|
||||||
|
|
||||||
|
### Physical steps for the owner (not code)
|
||||||
|
|
||||||
|
1. ~~Set the "3pin Volt" jumper to 5 V~~ **Verified by the owner:** the jumper is factory-set to
|
||||||
|
5 V (continuity: middle pad to 5V pad only). The GPIO12/13 ports already supply 5 V.
|
||||||
|
2. Pan servo (in the base) → GPIO12 port, tilt servo (camera yoke) → GPIO13 port.
|
||||||
|
3. The camera ribbon drops from the yoke straight into the Pi–HAT gap; 15–20 cm should do. Only the
|
||||||
|
tilt flexes it, so leave a gentle loop.
|
||||||
|
4. Check whether the camera lens is glued; if not, it can be turned to focus at desk distance.
|
||||||
|
5. Power the robot from a **5.1 V 3 A USB-C supply through the power/data splitter**; servos share
|
||||||
|
the Pi's 5 V rail. Fit a fan to the HAT's fan header.
|
||||||
|
6. Connect the splitter's data end to whichever host is in use. The link uses fixed addresses
|
||||||
|
(package 0b), so no DHCP reservation or Wi-Fi network is needed.
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Host: NixOS or Omarchy Raspberry Pi 4 + BrainCraft HAT (Ubuntu 24.04)
|
||||||
|
────────────────────── ──────────────────────────────────────────────
|
||||||
|
hermes-robot launcher (sets default audio devices)
|
||||||
|
Hermes voice mode / wake word PipeWire (not ROS)
|
||||||
|
mic ◄── pulse-tunnel source ◄── TCP 4713 ───── pipewire-pulse ◄── HAT mics (WM8960, in-kernel)
|
||||||
|
spkr ──► pulse-tunnel sink ─── TCP 4713 ────► pipewire-pulse ──► HAT speakers
|
||||||
|
│ levels
|
||||||
|
ROS 2 Jazzy graph ▼
|
||||||
|
MCP client ── HTTP :8765/mcp (bearer) ──────────► hermes_mcp ──action──► hermes_head ─► PWM GPIO12/13
|
||||||
|
│ └─ latest image ◄── camera_ros (OV5647)
|
||||||
|
└─ /hermes/state ─► hermes_face ─► ST7789 (SPI)
|
||||||
|
hermes_audio_state ─► /audio/levels ─┘
|
||||||
|
robot_state_publisher (URDF) ─► /tf
|
||||||
|
(optional) ROS tools in podman ◄── ROS traffic ──► (RViz, ros2 CLI, rosbag; Nav2 in V2)
|
||||||
|
```
|
||||||
|
|
||||||
|
All of the traffic above runs over **USB gadget Ethernet** on the Pi's USB-C port: the host is
|
||||||
|
`10.55.0.1`, the robot `10.55.0.2` (a /30 used for nothing else). Wi-Fi stays configured as a
|
||||||
|
fallback. On the host the only changes are the robot's address in the configs and a small
|
||||||
|
network config for the USB interface (package 0b).
|
||||||
|
|
||||||
|
### ROS 2 layout
|
||||||
|
|
||||||
|
One colcon workspace, `ros/` in this repository, built on the Pi. Packages:
|
||||||
|
|
||||||
|
| Package | Role |
|
||||||
|
|---|---|
|
||||||
|
| `hermes_description` | URDF/xacro of the robot, with visual meshes exported from `enclosure/` (base, turntable, cabinet, yoke, camera). Frames: `base_link`, `pan_link`, `tilt_link`, `camera_link`, `camera_optical_frame`. Joints `pan_joint`, `tilt_joint`. |
|
||||||
|
| `hermes_head` | Servo driver node (package 2). Accepts `control_msgs/action/FollowJointTrajectory` (or a small custom `LookAt` action if that proves simpler), publishes `/joint_states`. |
|
||||||
|
| `camera_ros` (upstream) | Publishes `/camera/image_raw`, `/camera/image_raw/compressed`, `/camera/camera_info`. Released as a binary for Jazzy. |
|
||||||
|
| `hermes_audio_state` | Reads PipeWire's speaker monitor and mic levels; publishes `/audio/levels` and speaking/listening flags. Audio itself never goes through ROS. |
|
||||||
|
| `hermes_face` | The face renderer (package 4) as a node; subscribes to `/hermes/state`, `/joint_states`, `/audio/levels`. |
|
||||||
|
| `hermes_mcp` | The MCP server (existing code, MCP SDK 2.x) wrapped in an `rclpy` node. `look()` returns the latest image; `turn_head()` calls the head action; it publishes `/hermes/state` (e.g. `looking`). **The MCP tool names are the stable contract with Hermes** and must not change when the internals do. |
|
||||||
|
| `hermes_bringup` | Launch files and parameters; one systemd unit runs `ros2 launch hermes_bringup robot.launch.py`. |
|
||||||
|
|
||||||
|
Conventions, so V2 slots in without renames: standard message types; REP 103 units and axes;
|
||||||
|
REP 105 frames (`base_link`, later `odom` and `map`); every node its own process, so one crash
|
||||||
|
can't take the others down (respawn in the launch file). Parameters live in YAML in
|
||||||
|
`hermes_bringup`, replacing most of `/etc/robot-mcp.env` (secrets such as the MCP token stay in
|
||||||
|
an env file readable only by the service user).
|
||||||
|
|
||||||
|
**Middleware.** Start with Jazzy's default (Fast DDS), with a fixed `ROS_DOMAIN_ID` and
|
||||||
|
`ROS_AUTOMATIC_DISCOVERY_RANGE=SUBNET`, which is simple on a two-machine /30. Evaluate
|
||||||
|
`rmw_zenoh` (router on the Pi) before V2, when Wi-Fi and a second robot computer arrive;
|
||||||
|
**verify** its maturity in Jazzy at that point.
|
||||||
|
|
||||||
|
**ROS on the hosts (podman only).** Hermes needs no ROS: it talks MCP to `hermes_mcp`. ROS on a
|
||||||
|
host is for tools (RViz, `ros2` CLI, rosbag) now and Nav2 later. Provide `host/ros-shell`, a script
|
||||||
|
that runs `docker.io/osrf/ros:jazzy-desktop` (or a small Containerfile built on it) under podman
|
||||||
|
with `--network=host` (needed for discovery), `--ipc=host`, `/dev/dri` passed through for RViz
|
||||||
|
rendering, and the Wayland/X11 sockets for display (Hyprland on Omarchy; whatever compositor the
|
||||||
|
NixOS host runs). distrobox over podman is an acceptable alternative for interactive use.
|
||||||
|
**Verify** RViz renders under both hosts' compositors. Same `ROS_DOMAIN_ID` and RMW settings as
|
||||||
|
the Pi.
|
||||||
|
|
||||||
|
## 4. Existing code (read these first)
|
||||||
|
|
||||||
|
| Path | What it is | Status for this build |
|
||||||
|
|---|---|---|
|
||||||
|
| `pi/robot_mcp_server.py` | MCP server (MCP Python SDK **2.x**, `MCPServer`, streamable HTTP, bearer auth). Tools: `look`, `turn_head`, `head_position`. | Becomes the `hermes_mcp` ROS node: keep the MCP layer and tool names; replace Picamera2 and the servo code with ROS calls. Tested against mcp 2.3 with stubbed hardware only. |
|
||||||
|
| `pi/install.sh` | One-shot Pi setup, written for Raspberry Pi OS. | Rewrite for Ubuntu 24.04 + ROS 2 Jazzy (package 5). |
|
||||||
|
| `pi/pipewire/50-network-server.conf` | Exposes pipewire-pulse on TCP 4713. | Keep as is. |
|
||||||
|
| `pi/pipewire/60-echo-cancel.conf.optional` | WebRTC echo cancellation. | Recommended **on** for this build: the HAT's mics sit next to its speakers, and a Pi 4 has CPU to spare. |
|
||||||
|
| `pi/nftables-robot.nft` | Limits 4713/8765 to the host's IP. | Change to: accept 4713/8765 (and 8766 if built) only on the USB interface from `10.55.0.1`; drop them on every other interface, Wi-Fi included. |
|
||||||
|
| `pi/robot-mcp.service` | systemd unit for the MCP server. | Replace with one unit that runs the ROS launch file. |
|
||||||
|
| `omarchy/*` | PipeWire tunnel config, `hermes-robot` launcher, Hermes config snippet. | Set `ROBOT_IP` to `10.55.0.2`; add the USB interface config (package 0b). Rename the folder `host/` and add NixOS equivalents alongside. |
|
||||||
|
| `host/check-pi-platform.sh` | Checks, from either host and without the Pi, that Ubuntu 24.04's Pi kernel ships every driver and overlay the robot needs. Tested only against a mock mirror. | Run it at the start of package 0a. |
|
||||||
|
| `docs/face-prototype.html` | Browser prototype of the face. **The reference implementation for package 4.** | Port, do not ship. |
|
||||||
|
| `enclosure/` | OpenSCAD model, STLs, renders, print and assembly guide. | Refine with real measurements (package 6b). |
|
||||||
|
|
||||||
|
## 5. Work packages
|
||||||
|
|
||||||
|
Each package ends with acceptance checks. Do not mark one done until its checks pass on the real
|
||||||
|
Pi. Where something is marked **verify**, it was not confirmed when this brief was written; check
|
||||||
|
the current upstream docs or the running system before relying on it.
|
||||||
|
|
||||||
|
### Package 0a: Platform proof on Ubuntu 24.04 (do first)
|
||||||
|
|
||||||
|
Before writing any robot code, prove the hardware works on the chosen platform.
|
||||||
|
|
||||||
|
**Already established off the Pi** (October 2026; recheck if much time has passed):
|
||||||
|
- The BrainCraft's audio is a **WM8960** codec at I²C `0x1A`. The seeed-voicecard "2-mic" overlay
|
||||||
|
Adafruit's guide installs is functionally identical to the **`wm8960-soundcard` overlay that
|
||||||
|
ships in the Raspberry Pi kernel itself**: same codec and address, same 12.288 MHz MCLK, same
|
||||||
|
`simple-audio-card` routing. So **no out-of-tree driver should be needed**, and kernel upgrades
|
||||||
|
should no longer break audio.
|
||||||
|
- Ubuntu 24.04's Pi kernel (`6.8.0-1065-raspi` at the time) ships `wm8960-soundcard.dtbo` and
|
||||||
|
`pwm-2chan.dtbo`. The Raspberry Pi kernel's config builds the WM8960 codec, simple card and Pi
|
||||||
|
I²S drivers as modules; that Ubuntu's build includes those modules was **not** confirmed.
|
||||||
|
- Ubuntu 24.04's `libcamera-ipa` includes the Pi 4 pipeline (`ipa_rpi_vc4.so`) and an `ov5647.json`
|
||||||
|
tuning file, so the stock libcamera should drive this camera; the Raspberry Pi libcamera fork
|
||||||
|
should not be needed on a Pi 4. `camera_ros` 0.7.0 is released for Jazzy.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. On a host, run `host/check-pi-platform.sh`. It downloads the current Ubuntu Pi kernel package
|
||||||
|
and reports any missing driver or overlay for audio, camera, PWM, SPI and the USB gadget.
|
||||||
|
Stop and tell the owner if anything is missing.
|
||||||
|
2. Flash Ubuntu Server 24.04 (arm64) for the Pi; install ROS 2 Jazzy (`ros-jazzy-ros-base` plus
|
||||||
|
the packages the workspace needs) from the official apt repository.
|
||||||
|
3. **Audio:** `dtoverlay=wm8960-soundcard` and `dtparam=audio=off` in `/boot/firmware/config.txt`.
|
||||||
|
The mainline driver's default mixer settings may leave the speaker or mic paths muted: set the
|
||||||
|
needed controls with `amixer` (seeed's `wm8960_asound.state` in the HinTak/seeed-voicecard repo
|
||||||
|
is a useful reference for which ones), then `alsactl store`. Record the final settings in the
|
||||||
|
repo.
|
||||||
|
4. **Camera:** `camera_auto_detect=1` (or `dtoverlay=ov5647`); check with `cam -l`, then
|
||||||
|
`ros2 run camera_ros camera_node`.
|
||||||
|
5. **Display:** SPI on; draw a test pattern on the ST7789 from Python. **Verify** which GPIO
|
||||||
|
backend Adafruit Blinka needs on Ubuntu 24.04 (likely `lgpio`/`rpi-lgpio`, not `RPi.GPIO`).
|
||||||
|
6. **PWM:** `dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4`; move one servo through sysfs.
|
||||||
|
|
||||||
|
**Fallback.** If audio or the camera can't be made to work on Ubuntu within a reasonable effort,
|
||||||
|
stop and ask the owner. The fallback is Raspberry Pi OS (where Adafruit and Raspberry Pi support
|
||||||
|
these parts directly) with ROS 2 Jazzy in a container that has the devices passed through.
|
||||||
|
|
||||||
|
**Accept when:** the check script passes; on the Pi, `aplay -l` shows the WM8960 card, a test tone
|
||||||
|
plays and both mics record; `cam -l` lists the OV5647 and `camera_ros` publishes images; the
|
||||||
|
display shows a test pattern; a servo moves on PWM; `ros2 topic list` works.
|
||||||
|
|
||||||
|
### Package 0b: The USB link (power and data over one cable)
|
||||||
|
|
||||||
|
Do this next: every later package's networking assumes it.
|
||||||
|
|
||||||
|
**Pi side**
|
||||||
|
- In `config.txt`, under `[all]`: `dtoverlay=dwc2,dr_mode=peripheral`.
|
||||||
|
- Build a composite gadget with configfs (`libcomposite`), started by a small script and a
|
||||||
|
`robot-usb-gadget.service` that runs before networking. Two functions:
|
||||||
|
- **NCM** Ethernet (Linux hosts handle it natively via `cdc_ncm`). Set fixed `host_addr` and
|
||||||
|
`dev_addr` MAC addresses so the host can match the interface reliably.
|
||||||
|
- **ACM serial**, with a getty on `ttyGS0`: a rescue console if networking breaks
|
||||||
|
(`screen /dev/ttyACM0 115200` from the host).
|
||||||
|
- Give `usb0` the static address `10.55.0.2/30` with **no gateway**, so the robot never routes its
|
||||||
|
internet traffic through the host by accident. On Ubuntu Server this means netplan (with
|
||||||
|
systemd-networkd); **verify** the gadget interface's name.
|
||||||
|
- Leave Wi-Fi configured. It is still how the Pi reaches the internet for `apt` and `pip`, and
|
||||||
|
the owner may want it as a fallback. Ask before disabling it.
|
||||||
|
- **Verify** that NCM gadget mode works on the Pi 4 with the current kernel. Fall back to ECM if
|
||||||
|
NCM misbehaves, and record which was used.
|
||||||
|
|
||||||
|
**Host side (NixOS or Omarchy, on either machine)**
|
||||||
|
- Give the gadget's interface `10.55.0.1/30`, matched by the fixed MAC address so it works on
|
||||||
|
whichever USB port and whichever machine it's plugged into.
|
||||||
|
- **Omarchy:** **verify** its network stack (e.g. NetworkManager, or iwd with systemd-networkd)
|
||||||
|
and write a config file for it that lives in `host/omarchy/`.
|
||||||
|
- **NixOS:** a snippet for `configuration.nix` in `host/nixos/` (e.g. `systemd.network` with a
|
||||||
|
`matchConfig.MACAddress` for the gadget). Declarative, so it survives rebuilds.
|
||||||
|
- Point the configs at the robot: `pulse.server.address = "tcp:10.55.0.2:4713"` in
|
||||||
|
`50-robot-tunnel.conf`, and `url: "http://10.55.0.2:8765/mcp"` in the Hermes snippet.
|
||||||
|
- The PipeWire tunnels already retry every 5 s and Hermes retries MCP servers on a cooldown, so
|
||||||
|
unplugging, sleeping the host or switching machines should recover without intervention.
|
||||||
|
Confirm this.
|
||||||
|
|
||||||
|
**Power.** The Pi 4 can't negotiate USB Power Delivery; it needs plain 5 V at up to 3 A, with
|
||||||
|
spikes when the servos move.
|
||||||
|
- **Default:** a USB-C power/data splitter (PiKVM-style). Power comes from the 5.1 V 3 A supply
|
||||||
|
and only the data lines go to the host. Pimoroni noted that its (now discontinued) version was
|
||||||
|
designed around the official Raspberry Pi supply; **verify** the owner's splitter and supply
|
||||||
|
together.
|
||||||
|
- Powering straight from the **Framework Desktop** is acceptable only if
|
||||||
|
`vcgencmd get_throttled` stays `0x0` through a full pan/tilt sweep while speaking.
|
||||||
|
- **Never power the robot from the ROG Flow Z13.** Its ports may not supply 3 A, and the robot
|
||||||
|
would drain the tablet's battery at roughly 10 W. Use the splitter with that machine.
|
||||||
|
|
||||||
|
**Firewall.** Rewrite `nftables-robot.nft`: accept 4713 and 8765 (and 8766 if built) only when
|
||||||
|
arriving on the USB interface from `10.55.0.1`, and drop those ports on every other interface.
|
||||||
|
SSH policy is the owner's call; ask.
|
||||||
|
|
||||||
|
**Accept when:**
|
||||||
|
- With the robot on the splitter and plugged into either host, the interface comes up and
|
||||||
|
`ping 10.55.0.2` answers within about 30 s of the Pi booting.
|
||||||
|
- `ssh` works over the link, and the serial console gives a login prompt.
|
||||||
|
- `hermes mcp test robot` passes and the audio tunnel devices appear, with no Wi-Fi involved
|
||||||
|
(test with the Pi's Wi-Fi off).
|
||||||
|
- Unplugging and replugging, or moving the cable to the other host, recovers on its own.
|
||||||
|
- `vcgencmd get_throttled` reads `0x0` after a servo sweep on the splitter.
|
||||||
|
|
||||||
|
### Package 1: Audio through the BrainCraft HAT
|
||||||
|
|
||||||
|
- Use the in-kernel `wm8960-soundcard` overlay proven in package 0a, with the stored mixer
|
||||||
|
settings. Only if that fails, fall back to Adafruit's documented route (the `HinTak/seeed-voicecard`
|
||||||
|
fork, branch matching `uname -r`), and then warn the owner that kernel upgrades can break audio.
|
||||||
|
- Remove `dtparam=i2s=on` / `dtoverlay=googlevoicehat-soundcard` from the install path.
|
||||||
|
- Keep `dtparam=audio=off` (the Pi's own headphone audio shares PWM hardware with package 2).
|
||||||
|
- Add the `hermes_audio_state` node: levels and speaking/listening flags from PipeWire's sink
|
||||||
|
monitor and source, published on `/audio/levels`.
|
||||||
|
- Make WirePlumber's default source and sink the HAT's codec, and enable the echo-cancel config,
|
||||||
|
exposing `robot_ec_source` / `robot_ec_sink`; then uncomment the `pulse.device` lines in
|
||||||
|
`omarchy/50-robot-tunnel.conf` (and tell the owner to update their copy).
|
||||||
|
- **Accept when:** `arecord -l` lists the codec; `pw-record` captures both mics; a test tone plays
|
||||||
|
from both speakers; from the host, `pactl list short sources` shows `robot_mic`, and
|
||||||
|
`hermes-robot` with `/voice on` completes one spoken exchange without the robot's own speech
|
||||||
|
triggering barge-in.
|
||||||
|
|
||||||
|
### Package 2: Servos on hardware PWM (`hermes_head` node)
|
||||||
|
|
||||||
|
- Add `dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4` to `config.txt` (GPIO12 = PWM0,
|
||||||
|
GPIO13 = PWM1 on the Pi 4).
|
||||||
|
- Write `hermes_head` as a ROS node: it accepts head targets as an action and publishes
|
||||||
|
`/joint_states` for `pan_joint` and `tilt_joint` (radians, per REP 103), so
|
||||||
|
`robot_state_publisher` keeps the camera frame correct in `/tf`. Also write
|
||||||
|
`hermes_description`'s URDF now, with meshes from the enclosure STLs.
|
||||||
|
- Inside the node, keep the backend interface with two implementations, selected by the
|
||||||
|
`servo_backend` parameter (`pwm` or `pca9685`, default `pwm`):
|
||||||
|
- `PwmHead`: drives `/sys/class/pwm/pwmchip0/pwm{0,1}` directly (export, `period=20000000`,
|
||||||
|
`duty_cycle` in ns, `enable=1`). No third-party library needed. **Verify** the chip number
|
||||||
|
and that the service user can write there (add a udev rule granting a group rather than
|
||||||
|
running as root; Ubuntu's defaults differ from Raspberry Pi OS here).
|
||||||
|
- `Pca9685Head`: the existing code, kept for anyone using a servo board.
|
||||||
|
- Pulse range is per-servo: expose `ROBOT_SERVO_MIN_US` / `ROBOT_SERVO_MAX_US` (start at 500 and
|
||||||
|
2400) and centre trims `ROBOT_PAN_TRIM_DEG` / `ROBOT_TILT_TRIM_DEG`. Keep the existing limits
|
||||||
|
and invert flags.
|
||||||
|
- Move gently: interpolate to the target over ~0.3 s instead of jumping, which also limits the
|
||||||
|
current spike on the shared 5 V rail. Disable PWM output after ~1 s idle to stop holding-jitter
|
||||||
|
and buzz, and re-enable on the next move.
|
||||||
|
- **Accept when:** `turn_head(0,0)`, `(±60,0)`, `(0,±30)` reach those positions with no jitter at
|
||||||
|
rest, and the Pi does not brown out (`vcgencmd get_throttled` stays `0x0`) during moves; RViz
|
||||||
|
in the host's ROS container shows the robot model's head following.
|
||||||
|
|
||||||
|
### Package 3: Camera
|
||||||
|
|
||||||
|
- Run upstream `camera_ros` at `1296x972` (the OV5647's full-field binned mode, so the whole field
|
||||||
|
of view is kept), publishing raw and compressed images and `camera_info`.
|
||||||
|
- Calibrate once with the `camera_calibration` package and store the result in
|
||||||
|
`hermes_bringup`; V2's perception needs it.
|
||||||
|
- `hermes_mcp`'s `look()` returns the most recent frame as JPEG (encode from raw, or pass the
|
||||||
|
compressed image through), and publishes `looking` on `/hermes/state`.
|
||||||
|
- **Accept when:** `camera_ros` publishes at a steady rate, `look()` returns a sharp-enough image of the
|
||||||
|
desk in ordinary room light, and orientation is right on the mounted head (use the HFLIP/VFLIP
|
||||||
|
settings).
|
||||||
|
|
||||||
|
### Package 4: The face (`hermes_face` node)
|
||||||
|
|
||||||
|
Its own node and process (respawned by the launch file), so a face crash never takes down the
|
||||||
|
MCP server or audio.
|
||||||
|
|
||||||
|
**Renderer.** Port `docs/face-prototype.html` to Python with NumPy. Keep its model:
|
||||||
|
|
||||||
|
- a 240×240 float32 energy buffer, multiplied each frame by `exp(-dt / persistence)`;
|
||||||
|
- the beam traced at constant speed, depositing energy per sample with bilinear splatting
|
||||||
|
(`np.add.at`), so slow parts of the path burn brighter and retraces leave faint streaks;
|
||||||
|
- two passes of a separable box blur for glow; tone mapping `1 - exp(-1.6v)` tinted with the
|
||||||
|
phosphor colour, overdriven spots whitening;
|
||||||
|
- the three shapes from the prototype: `flat`, `head` (smooth procedural wireframe with contour
|
||||||
|
lines, depth cueing and back-face dimming) and `lowpoly` (coarse faceted mesh with alternating
|
||||||
|
diagonals). `ROBOT_FACE_MODE` selects; **default `flat`** unless the owner says otherwise.
|
||||||
|
- both render styles: `phosphor` (afterglow, beam-speed brightness, retrace streaks) and `vector`
|
||||||
|
(buffer cleared each frame, lines saturated to even weight, gaps filled, retrace blanked, a
|
||||||
|
fading overlay for the shutter flash). `ROBOT_FACE_STYLE` selects; default `phosphor`.
|
||||||
|
- **Owner's choice: flat face, phosphor render, P7.** Other defaults as the prototype:
|
||||||
|
persistence 70 ms, focus 0.35, beam noise 0.3 px, 30 fps target.
|
||||||
|
|
||||||
|
**Display output.** Use Adafruit's `adafruit-circuitpython-rgb-display` ST7789 driver (CE0,
|
||||||
|
DC = GPIO25, backlight = GPIO26, 240×240, SPI baud ≥ 48 MHz; set rotation for the mounting).
|
||||||
|
Convert to RGB565 with NumPy and push whole frames. **Measure** the achieved frame rate; if it is
|
||||||
|
under ~20 fps, try the kernel's ST7789 framebuffer/DRM overlay and write to the framebuffer
|
||||||
|
instead, and note what was chosen.
|
||||||
|
|
||||||
|
**State inputs**, in priority order (highest wins):
|
||||||
|
|
||||||
|
| State | Source |
|
||||||
|
|---|---|
|
||||||
|
| `speaking` | RMS of the speaker output, read from the sink's monitor (`pw-record --target <sink>.monitor`, or `parec -d <sink>.monitor`, 16 kHz mono s16). Above threshold for >80 ms. **The mouth draws these actual samples**, trigger-locked on a rising zero crossing so the waveform stands still. |
|
||||||
|
| `looking` | Event from the MCP server when `look()` starts (shutter flash and squint), held ~1.2 s. |
|
||||||
|
| `listening` | Mic RMS above threshold while not speaking. Draw the mic waveform, dimmer. |
|
||||||
|
| `thinking` | **Fallback heuristic first:** from the end of a listening stretch until speaking starts, capped at 30 s. Then, as an upgrade, Hermes webhooks (below). |
|
||||||
|
| `idle` | Otherwise. Random blinks every 2–6 s. |
|
||||||
|
|
||||||
|
Head pose comes from `/joint_states`, so the eyes (flat mode) or the whole wireframe (head mode)
|
||||||
|
follow the real servos.
|
||||||
|
|
||||||
|
**Inputs are ROS topics** (this replaces the unix-socket design in earlier drafts):
|
||||||
|
`/hermes/state` (from `hermes_mcp`: `looking`, later `thinking` and V2 states such as `driving`),
|
||||||
|
`/joint_states`, and `/audio/levels`. For the mouth's actual waveform, the node may read
|
||||||
|
PipeWire's sink monitor directly rather than sending audio samples over ROS.
|
||||||
|
|
||||||
|
**Hermes webhooks (optional, last).** Hermes Agent can send signed webhooks for session activity,
|
||||||
|
completed turns and tool events. **Verify** in the current Hermes docs: the event names, payload
|
||||||
|
shape, and signature scheme. If they give a reliable "turn started" signal, add a small HTTP
|
||||||
|
listener on port 8766 that verifies the HMAC and sets `thinking`. Show tool calls as brief
|
||||||
|
flickers. Add the port to the nftables rules. If the docs don't support this well, keep the
|
||||||
|
heuristic and say so.
|
||||||
|
|
||||||
|
**Extras, if time allows:** mirror the state on the three DotStar LEDs (dim), and make the
|
||||||
|
HAT button toggle a mute that also shows on the face.
|
||||||
|
|
||||||
|
**Accept when:** the face runs at ≥20 fps with CPU under ~40% of one core; in a real exchange
|
||||||
|
it goes idle → listening while the owner speaks → thinking → speaking, with the mouth tracing
|
||||||
|
the actual voice; `look()` flashes it; `turn_head` moves it.
|
||||||
|
|
||||||
|
### Package 5: Installer and services
|
||||||
|
|
||||||
|
- Rewrite `pi/install.sh` for Ubuntu 24.04, keeping it re-runnable: ROS 2 Jazzy apt repository and
|
||||||
|
packages (`ros-jazzy-ros-base`, `camera_ros`, `robot_state_publisher`, `xacro`, and what the
|
||||||
|
nodes need); `config.txt` lines (`wm8960-soundcard`, PWM overlay, `audio=off`, SPI on, `dwc2`);
|
||||||
|
the stored ALSA mixer state; Python dependencies for the nodes (`mcp` 2.x, `numpy`,
|
||||||
|
`adafruit-circuitpython-rgb-display`, the Blinka GPIO backend) installed so `rclpy` nodes can
|
||||||
|
import them; `colcon build` of `ros/`; one systemd unit for the launch file; the PipeWire
|
||||||
|
configs including echo cancel; nftables; lingering for the user's PipeWire.
|
||||||
|
- `--servos` flag no longer needed; servos are standard on this build.
|
||||||
|
- Print the MCP token at the end, as now.
|
||||||
|
- Add `host/ros-shell` (the podman wrapper from section 3).
|
||||||
|
|
||||||
|
### Package 6: End-to-end check with Hermes
|
||||||
|
|
||||||
|
- On the host: `hermes mcp test robot`, then a voice session through `hermes-robot`.
|
||||||
|
- **Verify the one open question from the first build:** that the image returned by `look()`
|
||||||
|
actually reaches the model. If Hermes drops MCP image content, change `look()` to also save
|
||||||
|
the JPEG and return its path for Hermes's own vision tool, and document which path is used.
|
||||||
|
|
||||||
|
### Package 6b: Enclosure
|
||||||
|
|
||||||
|
`enclosure/hermes_robot.scad` is a parametric OpenSCAD model (cabinet, back cover, turntable plate,
|
||||||
|
base, camera yoke and cradle, plus four fit-test pieces), with STLs in `enclosure/stl/` and a
|
||||||
|
person-facing guide in `enclosure/README.md`. It was modelled from photos and datasheets, not
|
||||||
|
measurements.
|
||||||
|
|
||||||
|
- Work with the owner: they measure the `MEASURE` values with calipers and print the `test_*`
|
||||||
|
pieces; you update the parameters and re-export. Don't print the large parts until
|
||||||
|
`test_front` seats correctly on the real stack.
|
||||||
|
- Export with `openscad -D 'PART="<part>"' -o stl/<part>.stl hermes_robot.scad`, then check each
|
||||||
|
STL is watertight and a single body (e.g. with `trimesh`).
|
||||||
|
- **Power and data path:** a 30 × 25 mm USB/PWR splitter (PiKVM-style; ports RPI4, USB, PWR) sits
|
||||||
|
in a connector block on the back of the base. USB and PWR face out through the back wall; the
|
||||||
|
RPI4 socket faces a tunnel into the base, and a ~30 cm USB-C cable (right-angle at the Pi end)
|
||||||
|
runs up through the bearing to the Pi. The splitter's socket positions (`spl_*`) are estimates
|
||||||
|
from a product photo; confirm with `test_pod`.
|
||||||
|
- **URDF meshes:** export the moving parts in their assembled positions for `hermes_description`
|
||||||
|
(the model already has `pose_*` parts for this; see `enclosure/poster/README.md`).
|
||||||
|
- **Software implications:** pan now rotates the whole cabinet, so `turn_head` pan should move
|
||||||
|
smoothly (the interpolation in package 2 matters more), and the face's eyes should follow tilt
|
||||||
|
and small glances only. Set `ROBOT_FACE_ROTATION` for the portrait-mounted display.
|
||||||
|
|
||||||
|
### Package 7: Documentation
|
||||||
|
|
||||||
|
Rewrite `README.md` for this build: parts, the owner's physical steps from section 2, setup,
|
||||||
|
tuning, troubleshooting (including the kernel/driver coupling from package 1). Keep it a guide for
|
||||||
|
a person; this file stays as the engineering brief.
|
||||||
|
|
||||||
|
## 6. Design principles for the face
|
||||||
|
|
||||||
|
Proposed during design discussion; the owner may revise them. Until then, use them for
|
||||||
|
judgement calls:
|
||||||
|
|
||||||
|
1. **Show only real states.** The face signals what the agent is actually doing: listening,
|
||||||
|
thinking, speaking, looking. It does not perform emotions it has no basis for.
|
||||||
|
2. **The voice trace is literal.** The mouth is the real outgoing audio, not an animation timed
|
||||||
|
to it.
|
||||||
|
3. **It is plainly a machine.** Oscilloscope phosphor, vector lines, no attempt at a realistic
|
||||||
|
human face. Head mode is acceptable because it is clearly a wireframe instrument display, but
|
||||||
|
flat is the default.
|
||||||
|
4. **Gaze is honest.** Where the face looks matches where the camera points.
|
||||||
|
|
||||||
|
## 7. Configuration reference (target)
|
||||||
|
|
||||||
|
Network (package 0b): host `10.55.0.1/30`, robot `10.55.0.2/30`, gadget functions NCM + ACM,
|
||||||
|
fixed MAC addresses for both ends.
|
||||||
|
|
||||||
|
ROS: Jazzy, Fast DDS to start, fixed `ROS_DOMAIN_ID`, `ROS_AUTOMATIC_DISCOVERY_RANGE=SUBNET`.
|
||||||
|
|
||||||
|
The values below become ROS parameters in `hermes_bringup` (names may change to ROS style, e.g.
|
||||||
|
`face.mode`); only the MCP token stays in an env file. Current targets:
|
||||||
|
|
||||||
|
```
|
||||||
|
ROBOT_MCP_TOKEN=<hex> ROBOT_MCP_PORT=8765
|
||||||
|
ROBOT_CAM_SIZE=1296x972 ROBOT_CAM_HFLIP=0 ROBOT_CAM_VFLIP=0
|
||||||
|
ROBOT_SERVO_BACKEND=pwm ROBOT_SERVO_MIN_US=500 ROBOT_SERVO_MAX_US=2400
|
||||||
|
ROBOT_PAN_LIMIT=80 ROBOT_TILT_LIMIT=45
|
||||||
|
ROBOT_PAN_INVERT=0 ROBOT_TILT_INVERT=0
|
||||||
|
ROBOT_PAN_TRIM_DEG=0 ROBOT_TILT_TRIM_DEG=0
|
||||||
|
ROBOT_FACE_MODE=flat ROBOT_FACE_STYLE=phosphor
|
||||||
|
ROBOT_FACE_PHOSPHOR=P7 ROBOT_FACE_FPS=30
|
||||||
|
ROBOT_FACE_ROTATION=0 ROBOT_FACE_WEBHOOK_SECRET= (empty = webhooks off)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Unverified items (check before relying on them)
|
||||||
|
|
||||||
|
| Item | Why it's uncertain |
|
||||||
|
|---|---|
|
||||||
|
| WM8960 modules in Ubuntu's Pi kernel | Overlays confirmed; modules confirmed only in Raspberry Pi's kernel config. `host/check-pi-platform.sh` settles it. |
|
||||||
|
| WM8960 default mixer state on the BrainCraft | The in-kernel driver may start with paths muted; set once and store. |
|
||||||
|
| Adafruit Blinka on Ubuntu 24.04 | GPIO backend differs from Raspberry Pi OS. |
|
||||||
|
| RViz in podman on both hosts' compositors | Display and GPU passthrough untested. |
|
||||||
|
| `rmw_zenoh` on Jazzy | Only matters from V2; check maturity then. |
|
||||||
|
| PWM sysfs chip number and permissions | Differs by Pi model and OS release. |
|
||||||
|
| Servo pulse range | Varies between servo makes; calibrate. |
|
||||||
|
| ST7789 frame rate from Python | Untested on this HAT; may need the kernel driver. |
|
||||||
|
| Hermes webhook events and signatures | Seen in release coverage, not checked in docs. |
|
||||||
|
| Hermes passing MCP images to the model | Not documented when the first build was written. |
|
||||||
|
| `pipewire-pulse` TCP config merge behaviour | Written from docs, untested on the Pi. |
|
||||||
|
| NCM gadget mode on the Pi 4's current kernel | Widely used, but not tested on this build; ECM is the fallback. |
|
||||||
|
| Omarchy's network stack | Not checked; the USB interface config must match it. |
|
||||||
|
| NixOS host config for the gadget interface | Needs writing and testing on the owner's NixOS machine. |
|
||||||
|
| USB-C power output of the Framework Desktop's ports | Unknown; only matters if powering without the splitter. |
|
||||||
|
| Splitter compatibility with the owner's supply | Pimoroni's version was designed for the official Pi supply. |
|
||||||
|
|
||||||
|
## 9. Ground rules for the implementer
|
||||||
|
|
||||||
|
- Test on the Pi itself; the camera, codec, display and PWM cannot be meaningfully mocked.
|
||||||
|
- Keep the MCP server, audio, and face independent: any one failing must not stop the others.
|
||||||
|
- Never widen network exposure beyond the USB link (`10.55.0.1` on the gadget interface) without asking.
|
||||||
|
- Ask the owner before anything that reboots the Pi, upgrades the kernel, or holds packages.
|
||||||
|
- ROS on the hosts runs only in podman containers; don't install ROS natively on NixOS or Omarchy.
|
||||||
|
- Keep the MCP tool names stable; they are Hermes's contract with the robot.
|
||||||
|
- Keep audio out of ROS, and keep any future motor safety (V2) out of ROS too: it lives in
|
||||||
|
microcontroller firmware.
|
||||||
|
|
||||||
|
## 10. V2 direction: design for it, don't build it
|
||||||
|
|
||||||
|
The owner intends a mobile V2. V1 should make no choice that blocks it. Current thinking:
|
||||||
|
|
||||||
|
- **Platform:** the cabinet, yoke, tilt servo, face and audio move onto a differential-drive
|
||||||
|
chassis. Turning in place replaces the pan turntable, so `pan_joint` disappears from the URDF
|
||||||
|
and the base gains `odom` → `base_link` from wheel odometry. The USB link gives way to Wi-Fi
|
||||||
|
(firewalled to the host, ideally over Tailscale); a Wi-Fi drop must stop the motors.
|
||||||
|
- **Three layers of control**, each able to veto the one above and to carry on without it:
|
||||||
|
1. **Reflex**, on a Raspberry Pi Pico: motor control, encoders, bumper and proximity stops,
|
||||||
|
speed limits, a watchdog that stops the motors if the Pi goes quiet, battery cut-off. It may
|
||||||
|
speak micro-ROS, but its safety rules must not depend on ROS or the Pi.
|
||||||
|
2. **Autopilot**, on the Pi 4 plus an added **Jetson Orin Nano Super** (8 GB): perception
|
||||||
|
(free floor, people, doorways), mapping (slam_toolbox or Isaac ROS visual SLAM), Nav2, and
|
||||||
|
skills: go to a place, follow, turn toward a voice, find an object, dock. A recent guide
|
||||||
|
reports JetPack 7.2 runs Ubuntu 24.04 + Jazzy on the Orin Nano, with Isaac ROS 4.6 targeting
|
||||||
|
that stack; **verify** with NVIDIA before buying. The owner's older Jetson Nano (Ubuntu 18.04)
|
||||||
|
is fine for experiments only.
|
||||||
|
3. **Mind**, Hermes on the host: goals in, summaries out. It never sends motor commands or
|
||||||
|
receives raw video.
|
||||||
|
- **MCP contract for V2:** skills as long-running tools that return outcomes (`go_to(place)`,
|
||||||
|
`follow()`, `dock()`, `status()`), plus a way for Hermes to ask what has happened. How events
|
||||||
|
reach Hermes unprompted is an open question.
|
||||||
|
- **Environment:** no stairs and no pets, so cliff sensors are optional; front bumper and a few
|
||||||
|
forward distance sensors are required.
|
||||||
|
- **Owner decisions still open:** rooms the robot may not enter; a visible light whenever camera or
|
||||||
|
mic is live; whether Hermes may drive on its own initiative (e.g. from a scheduled task).
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# Hermes robot
|
||||||
|
|
||||||
|
A small Wi-Fi robot that gives Hermes Agent eyes, ears, a voice and (optionally) a
|
||||||
|
neck. All thinking happens on your Omarchy machine. The robot only takes pictures,
|
||||||
|
moves its head, and carries audio.
|
||||||
|
|
||||||
|
```
|
||||||
|
Omarchy machine Robot (Pi Zero 2 W)
|
||||||
|
─────────────── ───────────────────
|
||||||
|
hermes-robot ─► Hermes Agent
|
||||||
|
│ voice mode / wake word
|
||||||
|
├── default mic = "Robot microphone" ◄─┐
|
||||||
|
├── default sink = "Robot speaker" ─────┤ PipeWire pulse tunnel, TCP 4713
|
||||||
|
│ └─► pipewire-pulse ─► I²S mic(s) + amp
|
||||||
|
└── MCP tools: look(), turn_head() ──────► robot_mcp_server.py, HTTP 8765
|
||||||
|
├─ Camera Module 3
|
||||||
|
└─ PCA9685 ─► 2 servos (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parts
|
||||||
|
|
||||||
|
Prices are rough single-unit figures and will vary by vendor.
|
||||||
|
|
||||||
|
| Part | Notes | ≈ USD |
|
||||||
|
|---|---|---|
|
||||||
|
| Raspberry Pi Zero 2 W (the "WH" has headers soldered) | 2.4 GHz Wi-Fi only | 15–18 |
|
||||||
|
| microSD card, 16–32 GB | | 8 |
|
||||||
|
| Raspberry Pi Camera Module 3 | Autofocus; the "Wide" variant suits a desk robot | 25 |
|
||||||
|
| **Pi Zero camera cable** (22-pin to 15-pin) | The camera ships with the wrong cable for a Zero | 3 |
|
||||||
|
| INMP441 I²S MEMS microphone breakout, ×2 | One works; two give a stereo pair and a better signal | 3 each |
|
||||||
|
| MAX98357A I²S amplifier breakout | | 2–6 |
|
||||||
|
| Speaker, 4–8 Ω, 2–3 W, ~40 mm | | 3 |
|
||||||
|
| 5 V 3 A USB power supply | | 10 |
|
||||||
|
| *Optional head:* PCA9685 16-channel servo board | Clean hardware PWM over I²C | 6 |
|
||||||
|
| *Optional head:* 2× SG90 servos + pan/tilt bracket | | 8 |
|
||||||
|
| *Optional head:* 1000 µF electrolytic capacitor | Across the servo supply, against brown-outs | 1 |
|
||||||
|
| Dupont jumper wires, a bit of perfboard | | 5 |
|
||||||
|
|
||||||
|
Without the head, the robot is roughly $70; with it, about $85.
|
||||||
|
|
||||||
|
## Wiring
|
||||||
|
|
||||||
|
Pin numbers are **physical header pins** (1–40), not GPIO numbers. The microphone(s)
|
||||||
|
and amplifier share the I²S clock lines; that's expected.
|
||||||
|
|
||||||
|
**Microphones (INMP441)**
|
||||||
|
|
||||||
|
| INMP441 | Pi pin | |
|
||||||
|
|---|---|---|
|
||||||
|
| VDD | 1 (3.3 V) | **3.3 V only**, never 5 V |
|
||||||
|
| GND | 6 (GND) | |
|
||||||
|
| SCK | 12 (GPIO18, I²S BCLK) | |
|
||||||
|
| WS | 35 (GPIO19, I²S LRCLK) | |
|
||||||
|
| SD | 38 (GPIO20, I²S DIN) | |
|
||||||
|
| L/R | GND for mic A; 3.3 V for mic B | Selects left or right channel |
|
||||||
|
|
||||||
|
With two microphones, wire both identically except L/R, so their SD lines share pin 38.
|
||||||
|
|
||||||
|
**Amplifier (MAX98357A)**
|
||||||
|
|
||||||
|
| MAX98357A | Pi pin | |
|
||||||
|
|---|---|---|
|
||||||
|
| Vin | 2 (5 V) | |
|
||||||
|
| GND | 9 (GND) | |
|
||||||
|
| BCLK | 12 (GPIO18) | Shared with the mics |
|
||||||
|
| LRC | 35 (GPIO19) | Shared with the mics |
|
||||||
|
| DIN | 40 (GPIO21, I²S DOUT) | |
|
||||||
|
| SD, GAIN | leave unconnected | Defaults: enabled, 9 dB gain |
|
||||||
|
| Speaker + / − | to the speaker | |
|
||||||
|
|
||||||
|
**Optional head (PCA9685)**
|
||||||
|
|
||||||
|
| PCA9685 | Connects to | |
|
||||||
|
|---|---|---|
|
||||||
|
| VCC | Pi pin 17 (3.3 V) | Logic supply |
|
||||||
|
| GND | Pi pin 14 (GND) | |
|
||||||
|
| SDA | Pi pin 3 (GPIO2) | |
|
||||||
|
| SCL | Pi pin 5 (GPIO3) | |
|
||||||
|
| V+ terminal | **5 V from the power supply**, not from the Pi's header | Servos' power; add the capacitor here |
|
||||||
|
| Channel 0 | pan servo | |
|
||||||
|
| Channel 1 | tilt servo | |
|
||||||
|
|
||||||
|
Servos draw current spikes that will brown out the Pi if they share its header pins. Feed
|
||||||
|
the supply's 5 V to both the Pi (micro-USB) and the PCA9685's V+ terminal, with a common ground.
|
||||||
|
|
||||||
|
**Camera:** the Zero cable's narrow end goes in the Pi, contacts facing the board.
|
||||||
|
|
||||||
|
The `googlevoicehat-soundcard` overlay is the standard trick for this exact mic + amp pairing.
|
||||||
|
Google's AIY Voice HAT used the same chips, so its driver drives both in one full-duplex sound card.
|
||||||
|
|
||||||
|
## Setting up the Pi
|
||||||
|
|
||||||
|
1. Flash **Raspberry Pi OS Lite (64-bit)** with Raspberry Pi Imager. In its settings, set the
|
||||||
|
hostname (e.g. `robot`), your user, your Wi-Fi, and enable SSH.
|
||||||
|
2. Boot, find the Pi's IP, and give it a **DHCP reservation** on your router.
|
||||||
|
3. Copy the `pi/` folder over and run the installer, passing your Omarchy machine's IP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp -r pi robot.local:~/robot-setup
|
||||||
|
ssh robot.local
|
||||||
|
cd ~/robot-setup && chmod +x install.sh
|
||||||
|
./install.sh 192.168.1.20 # add --servos if you built the head
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer prints a token at the end. Keep it for the Hermes step.
|
||||||
|
4. After the reboot, check things on the Pi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arecord -l # a "googlevoicehat" card should be listed
|
||||||
|
wpctl status # its source and sink should appear under Audio
|
||||||
|
rpicam-still -o test.jpg # camera works
|
||||||
|
systemctl status robot-mcp # MCP server running
|
||||||
|
```
|
||||||
|
|
||||||
|
5. If the camera is mounted upside down, set both `ROBOT_CAM_HFLIP=1` and `ROBOT_CAM_VFLIP=1`
|
||||||
|
in `/etc/robot-mcp.env`, then run `sudo systemctl restart robot-mcp`.
|
||||||
|
|
||||||
|
## Setting up Omarchy
|
||||||
|
|
||||||
|
1. **Audio tunnel.** Copy `omarchy/50-robot-tunnel.conf` to
|
||||||
|
`~/.config/pipewire/pipewire.conf.d/`, replace both `ROBOT_IP`s, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user restart pipewire pipewire-pulse wireplumber
|
||||||
|
pactl list short sources | grep robot_mic # should show the robot
|
||||||
|
```
|
||||||
|
|
||||||
|
Omarchy's audio mixer (`wiremix`) will now list "Robot speaker" and "Robot microphone".
|
||||||
|
If `pactl` is missing, install it with `sudo pacman -S libpulse`.
|
||||||
|
|
||||||
|
2. **Hermes.** Add `ROBOT_MCP_TOKEN=<token>` to `~/.hermes/.env`. Merge
|
||||||
|
`omarchy/hermes-config-snippet.yaml` into `~/.hermes/config.yaml`, with `ROBOT_IP` replaced.
|
||||||
|
Then check the connection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes mcp test robot
|
||||||
|
```
|
||||||
|
|
||||||
|
The tools will appear in Hermes as `mcp_robot_look`, `mcp_robot_turn_head` and so on.
|
||||||
|
|
||||||
|
3. **Launcher.** Install the wrapper, which switches your default audio to the robot for the
|
||||||
|
length of the session and restores it afterwards:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -Dm755 omarchy/hermes-robot ~/.local/bin/hermes-robot
|
||||||
|
hermes-robot
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside Hermes, `/voice on` starts the conversation. Set up the wake word as described in the
|
||||||
|
Hermes docs ("Wake Word") so you never need the keyboard.
|
||||||
|
|
||||||
|
## First test
|
||||||
|
|
||||||
|
Say or type: *"Look around and tell me what's on my desk."* Hermes should call `look`, then
|
||||||
|
describe the scene.
|
||||||
|
|
||||||
|
**One thing to confirm early:** that the image actually reaches the model. The server returns a
|
||||||
|
standard MCP image block, and the test client sees it correctly. If Hermes answers as though it
|
||||||
|
saw nothing, check `~/.hermes/logs/agent.log`, and make sure your model supports vision. If
|
||||||
|
Hermes turns out to drop image blocks from MCP results, the fallback is a small change to
|
||||||
|
`look()` to save the JPEG and return a path for Hermes's own vision tool. Tell me if that happens.
|
||||||
|
|
||||||
|
## Tuning and troubleshooting
|
||||||
|
|
||||||
|
- **Mic too quiet** (the level bar barely moves): on the Pi, run
|
||||||
|
`wpctl set-volume @DEFAULT_AUDIO_SOURCE@ 2.5`. Alternatively, lower `voice.silence_threshold`
|
||||||
|
in Hermes. A single mic is about 6 dB quieter than a pair, because the unused channel is
|
||||||
|
averaged in.
|
||||||
|
- **Hermes interrupts itself:** this is the robot hearing its own speaker. Raise
|
||||||
|
`voice.barge_in_threshold_multiplier`, point the speaker away from the mics, or enable
|
||||||
|
`pi/pipewire/60-echo-cancel.conf.optional` and uncomment the `pulse.device` lines in the
|
||||||
|
tunnel config.
|
||||||
|
- **Audio crackles:** this means weak Wi-Fi. Raise `pulse.latency` to 300–400. The Zero 2 W has
|
||||||
|
only 2.4 GHz Wi-Fi and a small antenna, so keep it within reasonable range of the router.
|
||||||
|
- **Robot devices vanish after the Pi reboots:** the tunnels reconnect every 5 s by themselves.
|
||||||
|
Start `hermes-robot` again once they reappear.
|
||||||
|
- **Head turns the wrong way:** set `ROBOT_PAN_INVERT=1` or `ROBOT_TILT_INVERT=1` in
|
||||||
|
`/etc/robot-mcp.env`, then restart `robot-mcp`.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- The microphone stream (port 4713) and the camera server (port 8765) are open only to your
|
||||||
|
Omarchy machine's IP, via nftables on the Pi.
|
||||||
|
- The camera additionally requires the bearer token.
|
||||||
|
- Traffic is unencrypted on your LAN. If that matters to you, put both machines on Tailscale and
|
||||||
|
use the Pi's tailnet address in both configs.
|
||||||
|
- If your Omarchy machine's IP changes, re-run `install.sh` with the new one.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
pi/
|
||||||
|
install.sh one-shot Pi setup
|
||||||
|
robot_mcp_server.py MCP server: look, turn_head, head_position
|
||||||
|
requirements.txt MCP Python SDK 2.x
|
||||||
|
robot-mcp.service systemd unit
|
||||||
|
nftables-robot.nft port restriction
|
||||||
|
pipewire/50-network-server.conf exposes audio to the network
|
||||||
|
pipewire/60-echo-cancel.conf.optional
|
||||||
|
omarchy/
|
||||||
|
50-robot-tunnel.conf robot audio as local devices
|
||||||
|
hermes-config-snippet.yaml MCP and voice settings for Hermes
|
||||||
|
hermes-robot launcher that swaps default audio devices
|
||||||
|
```
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
<title>Hermes Scope Face</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&family=Saira+Condensed:wght@500;600;700&display=swap">
|
||||||
|
<style>
|
||||||
|
/* Layout: an instrument front panel. The CRT (the robot's 240x240 display) on the left, labelled controls on the right; stacks on phones. */
|
||||||
|
:root {
|
||||||
|
--panel: #d6dce2; /* cool instrument-grey front panel */
|
||||||
|
--panel-2: #c5cdd5; /* recessed control wells */
|
||||||
|
--ink: #1b2229;
|
||||||
|
--ink-2: #4d5965;
|
||||||
|
--rule: #a7b1bb;
|
||||||
|
--accent: #285a87; /* panel-print blue */
|
||||||
|
--lamp: #e8961e; /* indicator lamp amber */
|
||||||
|
--lamp-off: #8e979f;
|
||||||
|
--bezel: #23282e;
|
||||||
|
--bezel-edge: #3a4149;
|
||||||
|
--screen: #000000;
|
||||||
|
--focus: #285a87;
|
||||||
|
--font-label: "Saira Condensed", "Arial Narrow", "Roboto Condensed", sans-serif;
|
||||||
|
--font-body: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--panel: #1a2026; --panel-2: #232a31; --ink: #e2e7eb; --ink-2: #9aa5b0;
|
||||||
|
--rule: #37414b; --accent: #82b2dd; --lamp: #f0a43a; --lamp-off: #4a535c;
|
||||||
|
--bezel: #0f1215; --bezel-edge: #2c333a; --focus: #82b2dd;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--panel: #1a2026; --panel-2: #232a31; --ink: #e2e7eb; --ink-2: #9aa5b0;
|
||||||
|
--rule: #37414b; --accent: #82b2dd; --lamp: #f0a43a; --lamp-off: #4a535c;
|
||||||
|
--bezel: #0f1215; --bezel-edge: #2c333a; --focus: #82b2dd;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background: var(--panel); color: var(--ink); font-family: var(--font-body); font-size: 15px; line-height: 1.5; }
|
||||||
|
.wrap { max-width: 1060px; margin: 0 auto; padding-inline: 20px; padding-block: 28px 40px; display: grid; gap: 28px; }
|
||||||
|
|
||||||
|
header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 20px; border-bottom: 2px solid var(--ink); padding-bottom: 10px; }
|
||||||
|
h1 { margin: 0; font-family: var(--font-label); font-weight: 700; font-size: clamp(26px, 4vw, 34px); letter-spacing: .04em; text-transform: uppercase; line-height: 1.05; text-wrap: balance; }
|
||||||
|
h1 span { color: var(--accent); }
|
||||||
|
.model { font-family: var(--font-label); font-weight: 500; font-size: 14px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); }
|
||||||
|
|
||||||
|
.bench { display: grid; grid-template-columns: minmax(0, 520px) minmax(0, 1fr); gap: 28px; align-items: start; }
|
||||||
|
@media (max-width: 820px) { .bench { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
|
||||||
|
/* CRT */
|
||||||
|
.crt { background: var(--bezel); border-radius: 14px; padding: 20px 20px 14px; box-shadow: inset 0 0 0 1px var(--bezel-edge), 0 1px 0 var(--bezel-edge); display: grid; gap: 10px; }
|
||||||
|
.screen { position: relative; width: 100%; max-width: 480px; aspect-ratio: 1 / 1; margin: 0 auto; background: var(--screen); border-radius: 4px; overflow: hidden; box-shadow: 0 0 0 2px #000, inset 0 0 40px rgba(0,0,0,.9); }
|
||||||
|
.screen canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||||
|
.screen.pixels canvas#face { image-rendering: pixelated; }
|
||||||
|
#grat { pointer-events: none; }
|
||||||
|
.crt-foot { display: flex; justify-content: space-between; gap: 12px; font-family: var(--font-label); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: #8b96a1; }
|
||||||
|
.crt-foot b { font-weight: 600; color: #c3ccd4; }
|
||||||
|
|
||||||
|
/* Control panel */
|
||||||
|
.controls { display: grid; gap: 22px; min-width: 0; }
|
||||||
|
.group { display: grid; gap: 10px; }
|
||||||
|
.group-h { display: flex; align-items: center; gap: 10px; font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .16em; text-transform: uppercase; color: var(--accent); }
|
||||||
|
.group-h::after { content: ""; flex: 1; height: 1px; background: var(--rule); }
|
||||||
|
|
||||||
|
.states { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
|
||||||
|
@media (max-width: 460px) { .states { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||||
|
.key { appearance: none; border: 1px solid var(--rule); background: var(--panel-2); color: var(--ink); border-radius: 6px; padding: 10px 6px 8px; display: grid; justify-items: center; gap: 6px; cursor: pointer; font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .1em; text-transform: uppercase; box-shadow: 0 2px 0 var(--rule); transition: transform .06s, box-shadow .06s; }
|
||||||
|
.key:active { transform: translateY(2px); box-shadow: 0 0 0 var(--rule); }
|
||||||
|
.key .lamp { width: 9px; height: 9px; border-radius: 50%; background: var(--lamp-off); }
|
||||||
|
.key[aria-pressed="true"] .lamp { background: var(--lamp); box-shadow: 0 0 8px var(--lamp); }
|
||||||
|
.key .num { font-family: var(--font-mono); font-size: 11px; font-weight: 400; letter-spacing: 0; color: var(--ink-2); }
|
||||||
|
.key:focus-visible, .seg button:focus-visible, input:focus-visible, .switch:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; }
|
||||||
|
.switch { appearance: none; display: inline-flex; align-items: center; gap: 10px; border: 1px solid var(--rule); background: var(--panel-2); color: var(--ink); border-radius: 6px; padding: 7px 12px; cursor: pointer; font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .1em; text-transform: uppercase; }
|
||||||
|
.switch .lamp { width: 8px; height: 8px; border-radius: 50%; background: var(--lamp-off); }
|
||||||
|
.switch[aria-pressed="true"] .lamp { background: var(--lamp); box-shadow: 0 0 7px var(--lamp); }
|
||||||
|
|
||||||
|
.seg { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; background: var(--panel-2); }
|
||||||
|
.seg button { appearance: none; border: 0; border-right: 1px solid var(--rule); background: transparent; color: var(--ink-2); padding: 7px 11px; cursor: pointer; font-family: var(--font-mono); font-size: 13px; }
|
||||||
|
.seg button:last-child { border-right: 0; }
|
||||||
|
.seg button[aria-pressed="true"] { background: var(--ink); color: var(--panel); }
|
||||||
|
.seg-label { font-family: var(--font-label); font-weight: 600; font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); min-width: 64px; }
|
||||||
|
|
||||||
|
.knobs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 22px; }
|
||||||
|
@media (max-width: 460px) { .knobs { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
.knob { display: grid; gap: 4px; min-width: 0; }
|
||||||
|
.knob label { display: flex; justify-content: space-between; gap: 8px; font-family: var(--font-label); font-weight: 600; font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); }
|
||||||
|
.knob output { font-family: var(--font-mono); font-weight: 500; font-size: 12px; letter-spacing: 0; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||||
|
input[type="range"] { width: 100%; accent-color: var(--accent); margin: 0; }
|
||||||
|
|
||||||
|
.readout { font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); font-variant-numeric: tabular-nums; display: flex; flex-wrap: wrap; gap: 4px 18px; }
|
||||||
|
.readout b { color: var(--ink); font-weight: 500; }
|
||||||
|
|
||||||
|
/* Notes */
|
||||||
|
.notes { display: grid; gap: 12px; }
|
||||||
|
.map { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 22px; margin: 0; max-width: 78ch; }
|
||||||
|
.map dt { font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink); padding-top: 1px; }
|
||||||
|
.map dd { margin: 0; color: var(--ink-2); }
|
||||||
|
.map code, .notes code { font-family: var(--font-mono); font-size: 13px; color: var(--ink); }
|
||||||
|
.hint { color: var(--ink-2); font-size: 13px; margin: 0; }
|
||||||
|
kbd { font-family: var(--font-mono); font-size: 12px; border: 1px solid var(--rule); border-bottom-width: 2px; border-radius: 4px; padding: 0 5px; color: var(--ink); background: var(--panel-2); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<h1>Hermes <span>Scope</span> Face</h1>
|
||||||
|
<div class="model">Display prototype · BrainCraft 1.54″ TFT</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="bench">
|
||||||
|
<div class="crt">
|
||||||
|
<div class="screen pixels" id="screen">
|
||||||
|
<canvas id="face" width="240" height="240" aria-label="Animated oscilloscope face"></canvas>
|
||||||
|
<canvas id="grat" width="480" height="480" aria-hidden="true"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="crt-foot"><span>240 × 240 IPS</span><span>State <b id="stateLabel">Idle</b></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<section class="group" aria-labelledby="g-face">
|
||||||
|
<div class="group-h" id="g-face">Face</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="seg-label">Shape</span>
|
||||||
|
<div class="seg" id="modeSeg" role="group" aria-label="Face shape">
|
||||||
|
<button id="mode-flat" data-mode="flat" aria-pressed="false">Flat</button>
|
||||||
|
<button id="mode-head" data-mode="head" aria-pressed="true">Head</button>
|
||||||
|
<button id="mode-lowpoly" data-mode="lowpoly" aria-pressed="false">Low-poly</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="seg-label">Render</span>
|
||||||
|
<div class="seg" id="styleSeg" role="group" aria-label="Render style">
|
||||||
|
<button id="style-phosphor" data-style="phosphor" aria-pressed="true">Phosphor</button>
|
||||||
|
<button id="style-vector" data-style="vector" aria-pressed="false">Vector</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="modeNote">Wireframe head that turns with the servos.</p>
|
||||||
|
<div class="knobs" id="meshKnobs">
|
||||||
|
<div class="knob"><label for="mesh">Wireframe <output id="meshOut">45%</output></label><input id="mesh" type="range" min="0" max="100" value="45"></div>
|
||||||
|
<div class="knob"><label for="density">Mesh lines <output id="densityOut">13 × 15</output></label><input id="density" type="range" min="6" max="20" value="13"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group" aria-labelledby="g-state">
|
||||||
|
<div class="group-h" id="g-state">Agent state</div>
|
||||||
|
<div class="states" id="states">
|
||||||
|
<button class="key" id="k-idle" data-state="idle" aria-pressed="true"><span class="lamp"></span>Idle<span class="num">1</span></button>
|
||||||
|
<button class="key" id="k-listen" data-state="listening" aria-pressed="false"><span class="lamp"></span>Listen<span class="num">2</span></button>
|
||||||
|
<button class="key" id="k-think" data-state="thinking" aria-pressed="false"><span class="lamp"></span>Think<span class="num">3</span></button>
|
||||||
|
<button class="key" id="k-look" data-state="looking" aria-pressed="false"><span class="lamp"></span>Look<span class="num">4</span></button>
|
||||||
|
<button class="key" id="k-speak" data-state="speaking" aria-pressed="false"><span class="lamp"></span>Speak<span class="num">5</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="switch" id="seqBtn" aria-pressed="true"><span class="lamp"></span>Demo sequence</button>
|
||||||
|
<span class="hint">Keys <kbd>1</kbd>–<kbd>5</kbd> pick a state, <kbd>Space</kbd> toggles the sequence, <kbd>F</kbd> cycles shape, <kbd>V</kbd> switches render.</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group" aria-labelledby="g-crt">
|
||||||
|
<div class="group-h" id="g-crt">Phosphor</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="seg" id="phosSeg" role="group" aria-label="Phosphor type">
|
||||||
|
<button id="ph-p31" data-p="P31" aria-pressed="true">P31</button>
|
||||||
|
<button id="ph-p1" data-p="P1" aria-pressed="false">P1</button>
|
||||||
|
<button id="ph-p7" data-p="P7" aria-pressed="false">P7</button>
|
||||||
|
<button id="ph-p3" data-p="P3" aria-pressed="false">P3</button>
|
||||||
|
</div>
|
||||||
|
<span class="hint" id="phosNote">P31: bright green, the usual scope tube.</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="vectorNote" hidden>Vector render draws clean, even lines with no afterglow, so persistence, focus and beam noise have no effect.</p>
|
||||||
|
<div class="knobs">
|
||||||
|
<div class="knob"><label for="persist">Persistence <output id="persistOut">70 ms</output></label><input id="persist" type="range" min="12" max="300" value="70"></div>
|
||||||
|
<div class="knob"><label for="inten">Intensity <output id="intenOut">1.0×</output></label><input id="inten" type="range" min="30" max="250" value="100"></div>
|
||||||
|
<div class="knob"><label for="focus">Focus <output id="focusOut">sharp</output></label><input id="focus" type="range" min="0" max="100" value="35"></div>
|
||||||
|
<div class="knob"><label for="jitter">Beam noise <output id="jitterOut">0.3 px</output></label><input id="jitter" type="range" min="0" max="20" value="3"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group" aria-labelledby="g-head">
|
||||||
|
<div class="group-h" id="g-head">Head position</div>
|
||||||
|
<div class="knobs">
|
||||||
|
<div class="knob"><label for="pan">Pan <output id="panOut">+0°</output></label><input id="pan" type="range" min="-80" max="80" value="0"></div>
|
||||||
|
<div class="knob"><label for="tilt">Tilt <output id="tiltOut">+0°</output></label><input id="tilt" type="range" min="-45" max="45" value="0"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group" aria-labelledby="g-disp">
|
||||||
|
<div class="group-h" id="g-disp">Display</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="seg-label">Refresh</span>
|
||||||
|
<div class="seg" id="fpsSeg" role="group" aria-label="Frame rate">
|
||||||
|
<button id="fps-60" data-fps="60" aria-pressed="false">60</button>
|
||||||
|
<button id="fps-30" data-fps="30" aria-pressed="true">30</button>
|
||||||
|
<button id="fps-20" data-fps="20" aria-pressed="false">20</button>
|
||||||
|
</div>
|
||||||
|
<button class="switch" id="pixBtn" aria-pressed="true"><span class="lamp"></span>Show pixels</button>
|
||||||
|
<button class="switch" id="gratBtn" aria-pressed="false"><span class="lamp"></span>Graticule</button>
|
||||||
|
</div>
|
||||||
|
<div class="readout"><span>Beam <b id="roPts">0</b> pts/frame</span><span>Render <b id="roFps">0</b> fps</span><span>Decay <b id="roDecay">0.00</b>/frame</span></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="notes" aria-labelledby="g-map">
|
||||||
|
<div class="group-h" id="g-map">Where each state comes from on the robot</div>
|
||||||
|
<dl class="map">
|
||||||
|
<dt>Speak</dt><dd>The mouth is the waveform going to the robot's own speaker, read from the PipeWire output on the Pi. Here it is synthesized speech.</dd>
|
||||||
|
<dt>Listen</dt><dd>The microphone signal on the Pi, drawn dimmer. Here it is a second synthetic voice at a higher pitch.</dd>
|
||||||
|
<dt>Look</dt><dd>Triggered inside <code>look()</code> on the Pi: a shutter flash and a squint before the photo.</dd>
|
||||||
|
<dt>Think</dt><dd>Needs Hermes to say so: a turn-started or tool event sent to a small listener on the Pi. The eyes become rotating 3:2 Lissajous figures.</dd>
|
||||||
|
<dt>Shapes</dt><dd>Flat is the simplest to read across a room. Head and Low-poly trade some of that for showing direction: Head is a smooth contoured mesh, Low-poly a coarse faceted one. Phosphor render draws them as a scope would, with afterglow and beam-speed brightness; Vector render draws clean, even lines with no trails.</dd>
|
||||||
|
<dt>Head</dt><dd>In the head shapes the whole wireframe turns with the pan and tilt servos, so the face looks where the camera looks; in flat mode only the eyes shift. The head also cocks while listening and glances up while thinking.</dd>
|
||||||
|
</dl>
|
||||||
|
<p class="hint">The renderer is the same math the Pi would run: a 240 × 240 brightness buffer that decays each frame, a beam path traced at constant speed so slow parts burn brighter, and a small blur for glow. It ports to NumPy and Pillow almost line for line.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const W = 240, H = 240, N = W * H;
|
||||||
|
const canvas = document.getElementById('face');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const img = ctx.createImageData(W, H);
|
||||||
|
const buf = new Float32Array(N); // phosphor energy
|
||||||
|
const tmp = new Float32Array(N);
|
||||||
|
const glow = new Float32Array(N);
|
||||||
|
|
||||||
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- settings
|
||||||
|
const PHOSPHORS = {
|
||||||
|
P31: { rgb: [0.30, 1.00, 0.45], note: 'P31: bright green, the usual scope tube.' },
|
||||||
|
P1: { rgb: [0.45, 1.00, 0.25], note: 'P1: yellower green, older and longer-glowing tubes.' },
|
||||||
|
P7: { rgb: [0.62, 0.80, 1.00], note: 'P7: blue-white flash with a long yellow afterglow; radar screens.' },
|
||||||
|
P3: { rgb: [1.00, 0.68, 0.18], note: 'P3: amber, as on old terminals.' },
|
||||||
|
};
|
||||||
|
const S = {
|
||||||
|
phos: 'P31', persistMs: 70, intensity: 1.0, focus: 0.35, jitter: 0.3,
|
||||||
|
pan: 0, tilt: 0, fps: 30, pixels: true, graticule: false, sequence: !reduceMotion,
|
||||||
|
mode: 'head', style: 'phosphor', mesh: 0.45, density: 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- state machine
|
||||||
|
const STATES = ['idle', 'listening', 'thinking', 'looking', 'speaking'];
|
||||||
|
const LABEL = { idle: 'Idle', listening: 'Listening', thinking: 'Thinking', looking: 'Looking', speaking: 'Speaking' };
|
||||||
|
const SEQ = [['idle', 3.5], ['listening', 3.6], ['thinking', 2.4], ['looking', 1.3], ['thinking', 1.4], ['speaking', 6.5]];
|
||||||
|
let state = 'idle', stateT0 = 0, seqIdx = 0, seqT0 = 0;
|
||||||
|
let lookFlashPending = false;
|
||||||
|
|
||||||
|
function setState(s, now) {
|
||||||
|
if (s === state && s !== 'looking') return;
|
||||||
|
state = s; stateT0 = now;
|
||||||
|
if (s === 'looking') lookFlashPending = true;
|
||||||
|
document.getElementById('stateLabel').textContent = LABEL[s];
|
||||||
|
document.querySelectorAll('#states .key').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.state === s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// smoothed animation parameters
|
||||||
|
const A = { think: 0, open: 1, eyeScale: 1, mouthSpeak: 0, mouthListen: 0, smile: 1, panX: 0, tiltY: 0, yaw: 0, pitch: 0, roll: 0 };
|
||||||
|
const approach = (cur, target, rate, dt) => cur + (target - cur) * (1 - Math.exp(-rate * dt));
|
||||||
|
|
||||||
|
// blink scheduler
|
||||||
|
let nextBlink = 2.5, blinkT = -1;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- synthetic voices
|
||||||
|
function hash(i, seed) { let x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453; return x - Math.floor(x); }
|
||||||
|
const VOWELS = [[730, 1090], [270, 2290], [300, 870], [530, 1840], [570, 840], [660, 1720]];
|
||||||
|
|
||||||
|
function voice(T, seed, baseF0, rate) {
|
||||||
|
const u = T * rate, i = Math.floor(u), frac = u - i;
|
||||||
|
const pause = hash(i, seed) < 0.16;
|
||||||
|
const amp = pause ? 0 : 0.55 + 0.45 * hash(i + 7, seed);
|
||||||
|
const env = amp * Math.pow(Math.sin(Math.PI * frac), 0.7);
|
||||||
|
const v0 = VOWELS[Math.floor(hash(i, seed + 3) * VOWELS.length)];
|
||||||
|
const v1 = VOWELS[Math.floor(hash(i + 1, seed + 3) * VOWELS.length)];
|
||||||
|
const m = frac * frac * (3 - 2 * frac);
|
||||||
|
const F1 = v0[0] + (v1[0] - v0[0]) * m, F2 = v0[1] + (v1[1] - v0[1]) * m;
|
||||||
|
const f0 = baseF0 * (1 + 0.12 * Math.sin(T * 1.7 + seed) + 0.06 * Math.sin(T * 4.3));
|
||||||
|
const w = [];
|
||||||
|
let norm = 0;
|
||||||
|
for (let k = 1; k <= 9; k++) {
|
||||||
|
const f = k * f0;
|
||||||
|
const d1 = (f - F1) / 160, d2 = (f - F2) / 260;
|
||||||
|
const a = Math.exp(-d1 * d1) + 0.65 * Math.exp(-d2 * d2) + (k === 1 ? 0.35 : 0.04);
|
||||||
|
w.push(a); norm += a;
|
||||||
|
}
|
||||||
|
const noise = frac < 0.14 && !pause ? (0.14 - frac) * 3.2 : 0;
|
||||||
|
return { env, f0, w, norm, noise };
|
||||||
|
}
|
||||||
|
// value of the voice at local time t (seconds) inside a trigger-locked window
|
||||||
|
function voiceSample(v, t, phase) {
|
||||||
|
let s = 0;
|
||||||
|
for (let k = 0; k < v.w.length; k++) s += v.w[k] * Math.sin(2 * Math.PI * (k + 1) * v.f0 * t + phase * (k + 1));
|
||||||
|
return s / v.norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- beam deposition
|
||||||
|
let ptsThisFrame = 0;
|
||||||
|
function deposit(x, y, e) {
|
||||||
|
if (S.jitter > 0 && S.style === 'phosphor') { x += (Math.random() - 0.5) * 2 * S.jitter; y += (Math.random() - 0.5) * 2 * S.jitter; }
|
||||||
|
const xi = Math.floor(x), yi = Math.floor(y);
|
||||||
|
if (xi < 0 || yi < 0 || xi >= W - 1 || yi >= H - 1) return;
|
||||||
|
const fx = x - xi, fy = y - yi, i = yi * W + xi;
|
||||||
|
buf[i] += e * (1 - fx) * (1 - fy);
|
||||||
|
buf[i + 1] += e * fx * (1 - fy);
|
||||||
|
buf[i + W] += e * (1 - fx) * fy;
|
||||||
|
buf[i + W + 1] += e * fx * fy;
|
||||||
|
ptsThisFrame++;
|
||||||
|
}
|
||||||
|
// trace a parametric stroke at constant beam time: n samples, energy e each
|
||||||
|
function stroke(fn, n, e) {
|
||||||
|
let px = null, py = null;
|
||||||
|
for (let j = 0; j <= n; j++) {
|
||||||
|
const p = fn(j / n);
|
||||||
|
// vector render keeps line weight even: fill any gap a fast-moving stretch leaves
|
||||||
|
if (S.style === 'vector' && px !== null) {
|
||||||
|
const gaps = Math.floor(Math.hypot(p[0] - px, p[1] - py) / 0.7);
|
||||||
|
for (let g = 1; g <= gaps; g++) { const t = g / (gaps + 1); deposit(px + (p[0] - px) * t, py + (p[1] - py) * t, e); }
|
||||||
|
}
|
||||||
|
deposit(p[0], p[1], e); px = p[0]; py = p[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fast retrace between strokes: few samples, faint streak
|
||||||
|
let beamX = 120, beamY = 120;
|
||||||
|
function jumpTo(x, y, e) {
|
||||||
|
if (S.style === 'phosphor') { // a vector display blanks the beam on retrace
|
||||||
|
const n = Math.max(4, Math.round(Math.hypot(x - beamX, y - beamY) / 1.5));
|
||||||
|
for (let j = 1; j <= n; j++) deposit(beamX + (x - beamX) * j / n, beamY + (y - beamY) * j / n, e * 0.045);
|
||||||
|
}
|
||||||
|
beamX = x; beamY = y;
|
||||||
|
}
|
||||||
|
function trace(fn, n, e) { const p0 = fn(0); jumpTo(p0[0], p0[1], e); stroke(fn, n, e); const p1 = fn(1); beamX = p1[0]; beamY = p1[1]; }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 3D head (procedural, model units: head height 2)
|
||||||
|
const gauss = (d, s) => Math.exp(-(d / s) * (d / s));
|
||||||
|
const sstep = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
|
||||||
|
const HR = { x: 0.74, y: 1.0, z: 0.82 };
|
||||||
|
const taper = y => 1 - 0.34 * sstep(-0.15, -0.95, y) - 0.06 * sstep(0.55, 1.0, y); // jaw and crown
|
||||||
|
function relief(x, y) { // forward displacement that sculpts the face
|
||||||
|
const ax = Math.abs(x);
|
||||||
|
return 0.22 * gauss(x, 0.075) * gauss(y + 0.10, 0.20) // nose bridge
|
||||||
|
+ 0.13 * gauss(x, 0.10) * gauss(y + 0.32, 0.08) // nose tip
|
||||||
|
- 0.12 * gauss(ax - 0.31, 0.14) * gauss(y - 0.12, 0.09) // eye sockets
|
||||||
|
+ 0.05 * gauss(y - 0.27, 0.06) * gauss(x, 0.45) // brow
|
||||||
|
+ 0.04 * gauss(ax - 0.38, 0.12) * gauss(y + 0.06, 0.12) // cheekbones
|
||||||
|
+ 0.07 * gauss(x, 0.17) * gauss(y + 0.53, 0.07) // lips
|
||||||
|
- 0.035 * gauss(x, 0.16) * gauss(y + 0.53, 0.018) // mouth line
|
||||||
|
+ 0.05 * gauss(x, 0.15) * gauss(y + 0.82, 0.08); // chin
|
||||||
|
}
|
||||||
|
function surf(th, ph) { // th: 0 at crown, ph: 0 facing the viewer
|
||||||
|
const y = HR.y * Math.cos(th), s = Math.sin(th);
|
||||||
|
const x = HR.x * taper(y) * s * Math.sin(ph);
|
||||||
|
const z = HR.z * s * Math.cos(ph) + relief(x, y) * Math.pow(Math.max(0, Math.cos(ph)), 1.5);
|
||||||
|
return [x, y, z];
|
||||||
|
}
|
||||||
|
function surfAt(x, y, lift) { // the surface point in front of (x, y)
|
||||||
|
const c = Math.max(-0.999, Math.min(0.999, y / HR.y)), s = Math.sqrt(1 - c * c);
|
||||||
|
const sp = Math.max(-1, Math.min(1, x / (HR.x * taper(y) * s))), cp = Math.sqrt(1 - sp * sp);
|
||||||
|
return [x, y, HR.z * s * cp + relief(x, y) * Math.pow(cp, 1.5) + lift];
|
||||||
|
}
|
||||||
|
// feature contours, as (x, y) control points laid onto the surface
|
||||||
|
function contour(ctrl, lift, n = 60) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
const f = i / n * (ctrl.length - 1), k = Math.min(ctrl.length - 2, Math.floor(f)), t = f - k;
|
||||||
|
// Catmull-Rom through the control points
|
||||||
|
const p0 = ctrl[Math.max(0, k - 1)], p1 = ctrl[k], p2 = ctrl[k + 1], p3 = ctrl[Math.min(ctrl.length - 1, k + 2)];
|
||||||
|
const cr = (a, b, c, d) => 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t * t + (-a + 3 * b - 3 * c + d) * t * t * t);
|
||||||
|
out.push(surfAt(cr(p0[0], p1[0], p2[0], p3[0]), cr(p0[1], p1[1], p2[1], p3[1]), lift));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const FEATURES = [
|
||||||
|
contour([[-0.05, 0.07], [-0.07, -0.14], [-0.09, -0.29], [-0.12, -0.36], [-0.06, -0.40], [0, -0.39], [0.06, -0.40], [0.12, -0.36], [0.09, -0.29], [0.07, -0.14], [0.05, 0.07]], 0.012, 90), // nose
|
||||||
|
contour([[-0.47, 0.20], [-0.38, 0.265], [-0.26, 0.28], [-0.15, 0.25]], 0.01), // right brow
|
||||||
|
contour([[0.15, 0.25], [0.26, 0.28], [0.38, 0.265], [0.47, 0.20]], 0.01), // left brow
|
||||||
|
contour([[-0.22, -0.58], [-0.1, -0.63], [0, -0.64], [0.1, -0.63], [0.22, -0.58]], 0.012), // lower lip
|
||||||
|
];
|
||||||
|
let MESH = [];
|
||||||
|
function buildMesh(rings) {
|
||||||
|
const meridians = Math.round(rings * 1.15);
|
||||||
|
const P0 = -105 * Math.PI / 180, P1 = 105 * Math.PI / 180, T0 = 10 * Math.PI / 180, T1 = 152 * Math.PI / 180;
|
||||||
|
MESH = [];
|
||||||
|
for (let i = 0; i < rings; i++) {
|
||||||
|
const th = T0 + (T1 - T0) * i / (rings - 1), line = [];
|
||||||
|
for (let j = 0; j <= 60; j++) line.push(surf(th, P0 + (P1 - P0) * j / 60));
|
||||||
|
MESH.push(i % 2 ? line : line.reverse()); // alternate direction: shorter retrace, as a vector display would
|
||||||
|
}
|
||||||
|
for (let i = 0; i < meridians; i++) {
|
||||||
|
const ph = P0 + (P1 - P0) * i / (meridians - 1), line = [];
|
||||||
|
for (let j = 0; j <= 50; j++) line.push(surf(T0 + (T1 - T0) * j / 50, ph));
|
||||||
|
MESH.push(i % 2 ? line.reverse() : line);
|
||||||
|
}
|
||||||
|
return meridians;
|
||||||
|
}
|
||||||
|
|
||||||
|
// low-poly variant: a coarse vertex grid joined by straight edges, each quad split by a
|
||||||
|
// diagonal that alternates direction so the facets mirror about the nose
|
||||||
|
let LOWPOLY = [];
|
||||||
|
function buildLowPoly(rings) {
|
||||||
|
const cols = rings + 3; // odd-ish width so one column runs down the nose
|
||||||
|
const P0 = -100 * Math.PI / 180, P1 = 100 * Math.PI / 180, T0 = 14 * Math.PI / 180, T1 = 150 * Math.PI / 180;
|
||||||
|
const V = [];
|
||||||
|
for (let i = 0; i < rings; i++) {
|
||||||
|
const th = T0 + (T1 - T0) * i / (rings - 1), row = [];
|
||||||
|
for (let j = 0; j < cols; j++) row.push(surf(th, P0 + (P1 - P0) * j / (cols - 1)));
|
||||||
|
V.push(row);
|
||||||
|
}
|
||||||
|
LOWPOLY = [];
|
||||||
|
for (let i = 0; i < rings; i++) LOWPOLY.push(i % 2 ? V[i].slice().reverse() : V[i]); // rings
|
||||||
|
for (let j = 0; j < cols; j++) { const m = V.map(r => r[j]); LOWPOLY.push(j % 2 ? m.reverse() : m); } // meridians
|
||||||
|
for (let i = 0; i < rings - 1; i++) { // zigzag diagonals
|
||||||
|
const z = [];
|
||||||
|
for (let j = 0; j < cols; j++) z.push(V[j % 2 ? i + 1 : i][j]);
|
||||||
|
LOWPOLY.push(i % 2 ? z.reverse() : z);
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
const R = { cy: 1, sy: 0, cp: 1, sp: 0, cr: 1, sr: 0 };
|
||||||
|
function setRot(yaw, pitch, roll) {
|
||||||
|
R.cy = Math.cos(yaw); R.sy = Math.sin(yaw); R.cp = Math.cos(pitch); R.sp = Math.sin(pitch); R.cr = Math.cos(roll); R.sr = Math.sin(roll);
|
||||||
|
}
|
||||||
|
function rot(x, y, z) {
|
||||||
|
const x1 = x * R.cr - y * R.sr, y1 = x * R.sr + y * R.cr; // roll
|
||||||
|
const y2 = y1 * R.cp + z * R.sp, z2 = -y1 * R.sp + z * R.cp; // pitch: + looks up
|
||||||
|
return [x1 * R.cy + z2 * R.sy, y2, -x1 * R.sy + z2 * R.cy]; // yaw: + turns to viewer's right
|
||||||
|
}
|
||||||
|
function proj(p) {
|
||||||
|
const [x, y, z] = rot(p[0], p[1], p[2]);
|
||||||
|
const k = 84 / (1 - z / 5.5); // gentle perspective
|
||||||
|
// approximate surface normal (ellipsoid gradient) to tell front-facing from back-facing
|
||||||
|
const tx = HR.x * taper(p[1]);
|
||||||
|
const n = rot(p[0] / (tx * tx), p[1] / (HR.y * HR.y), p[2] / (HR.z * HR.z));
|
||||||
|
return [120 + x * k, 124 - y * k, z, n[2] / Math.hypot(n[0], n[1], n[2])];
|
||||||
|
}
|
||||||
|
// intensity depth cueing: far lines dimmer, the classic vector-display trick
|
||||||
|
const cue = z => 0.3 + 0.7 * Math.min(1, Math.max(0, (z + 0.55) / 1.45));
|
||||||
|
// floor: how much of a back-facing line still shows (wireframe see-through vs hidden)
|
||||||
|
function poly3(pts, e, step, floor) {
|
||||||
|
const q = pts.map(proj);
|
||||||
|
jumpTo(q[0][0], q[0][1], e);
|
||||||
|
for (let i = 1; i < q.length; i++) {
|
||||||
|
const a = q[i - 1], b = q[i];
|
||||||
|
const n = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / step));
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
const t = j / n;
|
||||||
|
const vis = floor + (1 - floor) * sstep(-0.12, 0.22, a[3] + (b[3] - a[3]) * t);
|
||||||
|
deposit(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, e * vis * cue(a[2] + (b[2] - a[2]) * t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const L = q[q.length - 1]; beamX = L[0]; beamY = L[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHead(T, E) {
|
||||||
|
setRot(A.yaw, A.pitch, A.roll);
|
||||||
|
|
||||||
|
// the wireframe: dim, constant-speed beam; back of the head fainter
|
||||||
|
if (S.mesh > 0) {
|
||||||
|
if (S.mode === 'lowpoly') {
|
||||||
|
for (const line of LOWPOLY) poly3(line, E * 0.75 * S.mesh, 0.85, 0.25);
|
||||||
|
} else {
|
||||||
|
for (const line of MESH) poly3(line, E * 0.6 * S.mesh, 0.85, 0.3);
|
||||||
|
// contour lines that make it read as a face from the front: nose, brows, lower lip
|
||||||
|
for (const line of FEATURES) poly3(line, E * 0.95 * S.mesh, 0.7, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eyes on the sockets: loops that become Lissajous figures while thinking
|
||||||
|
const rx = 0.135 * A.eyeScale, ry = 0.085 * A.eyeScale * A.open;
|
||||||
|
const listenPulse = state === 'listening' ? 0.15 * voice(T, 9, 190, 4.2).env : 0;
|
||||||
|
[-1, 1].forEach((side, idx) => {
|
||||||
|
const cx = 0.31 * side, cy = 0.125, ph = T * 1.6 + idx * Math.PI * 0.5, k = A.think, pts = [];
|
||||||
|
for (let j = 0; j <= 72; j++) {
|
||||||
|
const th = j / 72 * Math.PI * 2;
|
||||||
|
const ox = rx * Math.cos(th), oy = ry * Math.sin(th);
|
||||||
|
const lx = rx * Math.sin(3 * th + ph), ly = Math.max(ry, 0.07) * Math.sin(2 * th);
|
||||||
|
pts.push(surfAt(cx + ox + (lx - ox) * k, cy + oy + (ly - oy) * k, 0.02));
|
||||||
|
}
|
||||||
|
poly3(pts, E * (1.15 + listenPulse), 0.5, 0.04);
|
||||||
|
});
|
||||||
|
|
||||||
|
// mouth along the lips: smile at rest, the live waveform when speaking or listening
|
||||||
|
const sv = voice(T, 1, 118, 4.6), lv = voice(T, 9, 190, 4.2);
|
||||||
|
const winS = 2.4 / sv.f0, winL = 3.2 / lv.f0;
|
||||||
|
const speakAmp = 0.15 * A.mouthSpeak * sv.env, listenAmp = 0.07 * A.mouthListen * lv.env;
|
||||||
|
const smile = 0.045 * A.smile * (1 - A.mouthSpeak) * (1 - A.think * 0.8);
|
||||||
|
const half = 0.24 * (1 - 0.4 * A.think), pts = [];
|
||||||
|
for (let j = 0; j <= 90; j++) {
|
||||||
|
const u = j / 90, c = 2 * u - 1;
|
||||||
|
let y = -0.53 - smile * (1 - c * c) + smile * 0.4;
|
||||||
|
if (speakAmp > 0.003) y += speakAmp * voiceSample(sv, u * winS, 0) + (sv.noise ? sv.noise * 0.03 * A.mouthSpeak * Math.sin(u * 97 + T * 53) * Math.sin(u * 61 - T * 31) : 0);
|
||||||
|
if (listenAmp > 0.003) y += listenAmp * voiceSample(lv, u * winL, 0.4);
|
||||||
|
pts.push(surfAt(c * half, y, 0.025));
|
||||||
|
}
|
||||||
|
poly3(pts, E * 0.85 * (1 - 0.3 * A.mouthListen), 0.5, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- face geometry
|
||||||
|
function drawFace(T, dt) {
|
||||||
|
// energy per beam sample: constant energy per second whatever the refresh rate,
|
||||||
|
// and partly normalised so persistence mostly changes the trails, not the brightness
|
||||||
|
const E = 0.45 * (dt * 30) * (S.style === 'vector' ? 1 : S.intensity * Math.pow(70 / S.persistMs, 0.6));
|
||||||
|
if (S.mode !== 'flat') { drawHead(T, E); return; }
|
||||||
|
const ex = A.panX, ey = A.tiltY;
|
||||||
|
const eyes = [[82 + ex, 98 + ey], [158 + ex, 98 + ey]];
|
||||||
|
const rx = 21 * A.eyeScale, ry = 25 * A.eyeScale * A.open;
|
||||||
|
const listenPulse = state === 'listening' ? 0.12 * voice(T, 9, 190, 4.2).env : 0;
|
||||||
|
|
||||||
|
// eyes: ellipse morphing to a rotating 3:2 Lissajous figure while thinking
|
||||||
|
eyes.forEach(([cx, cy], idx) => {
|
||||||
|
const ph = T * 1.6 + idx * Math.PI * 0.5;
|
||||||
|
trace(u => {
|
||||||
|
const th = u * Math.PI * 2;
|
||||||
|
const ox = rx * Math.cos(th), oy = ry * Math.sin(th);
|
||||||
|
const lx = rx * 0.95 * Math.sin(3 * th + ph), ly = ry * 0.95 * Math.sin(2 * th);
|
||||||
|
const k = A.think;
|
||||||
|
return [cx + ox + (lx - ox) * k, cy + oy + (ly - oy) * k];
|
||||||
|
}, 260 + Math.round(160 * A.think), E * (1 + listenPulse));
|
||||||
|
});
|
||||||
|
|
||||||
|
// mouth: smile curve at rest, live waveform when speaking or listening
|
||||||
|
const mx0 = 70 + ex * 0.6, mx1 = 170 + ex * 0.6, my = 166 + ey * 0.6;
|
||||||
|
const sv = voice(T, 1, 118, 4.6), lv = voice(T, 9, 190, 4.2);
|
||||||
|
const winS = 2.4 / sv.f0, winL = 3.2 / lv.f0;
|
||||||
|
const speakAmp = 26 * A.mouthSpeak * sv.env, listenAmp = 12 * A.mouthListen * lv.env;
|
||||||
|
const smile = 7 * A.smile * (1 - A.mouthSpeak) * (1 - A.think * 0.8);
|
||||||
|
const thinkFlat = A.think;
|
||||||
|
const mouthW = (mx1 - mx0) * (1 - 0.45 * thinkFlat);
|
||||||
|
const mStart = (mx0 + mx1) / 2 - mouthW / 2;
|
||||||
|
trace(u => {
|
||||||
|
const x = mStart + u * mouthW;
|
||||||
|
const c = 2 * u - 1;
|
||||||
|
let y = my + smile * (1 - c * c) - smile * 0.4;
|
||||||
|
if (speakAmp > 0.05) y -= speakAmp * voiceSample(sv, u * winS, 0) + (sv.noise ? sv.noise * 5 * A.mouthSpeak * (Math.sin(u * 97 + T * 53) * Math.sin(u * 61 - T * 31)) : 0);
|
||||||
|
if (listenAmp > 0.05) y -= listenAmp * voiceSample(lv, u * winL, 0.4);
|
||||||
|
return [x, y];
|
||||||
|
}, 320, E * 0.65 * (0.95 - 0.3 * A.mouthListen));
|
||||||
|
jumpTo(eyes[0][0] - rx, eyes[0][1], E);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- per-frame update
|
||||||
|
function update(T, dt) {
|
||||||
|
// sequence
|
||||||
|
if (S.sequence) {
|
||||||
|
if (T - seqT0 >= SEQ[seqIdx][1]) { seqIdx = (seqIdx + 1) % SEQ.length; seqT0 = T; setState(SEQ[seqIdx][0], T); }
|
||||||
|
} else if (state === 'looking' && T - stateT0 > 1.4) {
|
||||||
|
setState('idle', T);
|
||||||
|
}
|
||||||
|
|
||||||
|
const st = state, inLook = st === 'looking', tl = T - stateT0;
|
||||||
|
A.think = approach(A.think, st === 'thinking' ? 1 : 0, 6, dt);
|
||||||
|
A.mouthSpeak = approach(A.mouthSpeak, st === 'speaking' ? 1 : 0, 10, dt);
|
||||||
|
A.mouthListen = approach(A.mouthListen, st === 'listening' ? 1 : 0, 8, dt);
|
||||||
|
A.smile = approach(A.smile, st === 'idle' ? 1 : st === 'speaking' ? 0.6 : 0.25, 4, dt);
|
||||||
|
let scaleT = st === 'listening' ? 1.08 : 1;
|
||||||
|
if (inLook) scaleT = tl < 0.18 ? 0.92 : 1.14;
|
||||||
|
A.eyeScale = approach(A.eyeScale, scaleT, 12, dt);
|
||||||
|
A.panX = approach(A.panX, -S.pan * 0.22, 7, dt); // positive pan = robot's left = viewer's right
|
||||||
|
A.tiltY = approach(A.tiltY, -S.tilt * 0.3, 7, dt);
|
||||||
|
|
||||||
|
// head pose: the servo angles, plus small gestures per state
|
||||||
|
const deg = Math.PI / 180;
|
||||||
|
let yawT = S.pan * deg, pitchT = S.tilt * deg, rollT = 0;
|
||||||
|
if (!reduceMotion) { yawT += 3.5 * deg * Math.sin(T * 0.37); pitchT += 2 * deg * Math.sin(T * 0.29); }
|
||||||
|
if (st === 'thinking') { yawT -= 11 * deg; pitchT += 9 * deg; } // glance up and aside
|
||||||
|
if (st === 'listening') { rollT = 7 * deg; pitchT -= 2 * deg; } // cock the head
|
||||||
|
if (st === 'speaking') pitchT += 3 * deg * voice(T, 1, 118, 4.6).env; // small nods on stressed syllables
|
||||||
|
if (inLook) pitchT -= 3 * deg;
|
||||||
|
A.yaw = approach(A.yaw, yawT, 5, dt);
|
||||||
|
A.pitch = approach(A.pitch, pitchT, 5, dt);
|
||||||
|
A.roll = approach(A.roll, rollT, 4, dt);
|
||||||
|
|
||||||
|
// blink (and the squint before a photo)
|
||||||
|
let openT = 1;
|
||||||
|
if (inLook && tl < 0.18) openT = 0.35;
|
||||||
|
if (st !== 'thinking' && !inLook) {
|
||||||
|
if (blinkT < 0 && T >= nextBlink) blinkT = T;
|
||||||
|
if (blinkT >= 0) {
|
||||||
|
const b = (T - blinkT) / 0.17;
|
||||||
|
if (b >= 1) { blinkT = -1; nextBlink = T + 2.2 + Math.random() * 4; }
|
||||||
|
else openT = Math.max(0.06, Math.abs(1 - 2 * b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
A.open = blinkT >= 0 ? openT : approach(A.open, openT, 18, dt);
|
||||||
|
|
||||||
|
if (lookFlashPending && tl > 0.16) { // shutter: bloom the whole tube once
|
||||||
|
if (S.style === 'phosphor') for (let i = 0; i < N; i++) buf[i] += 0.55;
|
||||||
|
else flash = 0.6; // vector render has no afterglow, so fade an overlay instead
|
||||||
|
lookFlashPending = false;
|
||||||
|
}
|
||||||
|
flash = Math.max(0, flash - dt * 2.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- tone mapping
|
||||||
|
function boxBlur(src, dst, r) {
|
||||||
|
// horizontal into tmp, vertical into dst
|
||||||
|
const inv = 1 / (2 * r + 1);
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
let acc = 0, row = y * W;
|
||||||
|
for (let x = -r; x <= r; x++) acc += src[row + Math.min(W - 1, Math.max(0, x))];
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
tmp[row + x] = acc * inv;
|
||||||
|
acc += src[row + Math.min(W - 1, x + r + 1)] - src[row + Math.max(0, x - r)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
let acc = 0;
|
||||||
|
for (let y = -r; y <= r; y++) acc += tmp[Math.min(H - 1, Math.max(0, y)) * W + x];
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
dst[y * W + x] = acc * inv;
|
||||||
|
acc += tmp[Math.min(H - 1, y + r + 1) * W + x] - tmp[Math.max(0, y - r) * W + x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let flash = 0;
|
||||||
|
function render() {
|
||||||
|
if (S.style === 'vector') return renderVector();
|
||||||
|
const r = 1 + Math.round(S.focus * 5);
|
||||||
|
boxBlur(buf, glow, r);
|
||||||
|
boxBlur(glow, glow, r);
|
||||||
|
const gAmt = 0.6 + S.focus * 2.2;
|
||||||
|
const core = 1.15 - S.focus * 0.55;
|
||||||
|
const [pr, pg, pb] = PHOSPHORS[S.phos].rgb;
|
||||||
|
const d = img.data;
|
||||||
|
for (let i = 0, j = 0; i < N; i++, j += 4) {
|
||||||
|
const v = buf[i] * core + glow[i] * gAmt;
|
||||||
|
const base = 1 - Math.exp(-v * 1.6);
|
||||||
|
const hot = Math.min(0.75, Math.max(0, v - 1.1) * 0.35); // overdriven spots whiten
|
||||||
|
d[j] = 255 * Math.min(1, pr * base + hot);
|
||||||
|
d[j + 1] = 255 * Math.min(1, pg * base + hot);
|
||||||
|
d[j + 2] = 255 * Math.min(1, pb * base + hot);
|
||||||
|
d[j + 3] = 255;
|
||||||
|
}
|
||||||
|
ctx.putImageData(img, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean vector look: this frame's lines only, saturated to an even weight, a faint halo, no trails
|
||||||
|
function renderVector() {
|
||||||
|
boxBlur(buf, glow, 1);
|
||||||
|
const [pr, pg, pb] = PHOSPHORS[S.phos].rgb;
|
||||||
|
const lift = 0.35; // pale the tint so lines read as light, not as phosphor
|
||||||
|
const cr = pr + (1 - pr) * lift, cg = pg + (1 - pg) * lift, cb = pb + (1 - pb) * lift;
|
||||||
|
const d = img.data, k = 2.6 * S.intensity;
|
||||||
|
for (let i = 0, j = 0; i < N; i++, j += 4) {
|
||||||
|
const v = Math.min(1, buf[i] * k) * 0.9 + Math.min(1, glow[i] * k) * 0.25 + flash * 0.35;
|
||||||
|
d[j] = 255 * Math.min(1, cr * v);
|
||||||
|
d[j + 1] = 255 * Math.min(1, cg * v);
|
||||||
|
d[j + 2] = 255 * Math.min(1, cb * v);
|
||||||
|
d[j + 3] = 255;
|
||||||
|
}
|
||||||
|
ctx.putImageData(img, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- loop
|
||||||
|
let last = performance.now(), acc = 0, frames = 0, fpsT = last;
|
||||||
|
function frame(nowMs) {
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
const step = 1000 / S.fps;
|
||||||
|
acc += nowMs - last; last = nowMs;
|
||||||
|
if (acc < step - 1) return;
|
||||||
|
const dt = Math.min(0.1, acc / 1000);
|
||||||
|
acc = acc % step;
|
||||||
|
const T = nowMs / 1000;
|
||||||
|
|
||||||
|
const decay = S.style === 'vector' ? 0 : Math.exp(-(dt * 1000) / S.persistMs);
|
||||||
|
if (decay === 0) buf.fill(0); else for (let i = 0; i < N; i++) buf[i] *= decay;
|
||||||
|
|
||||||
|
update(T, dt);
|
||||||
|
ptsThisFrame = 0;
|
||||||
|
drawFace(T, dt);
|
||||||
|
render();
|
||||||
|
|
||||||
|
frames++;
|
||||||
|
if (nowMs - fpsT > 500) {
|
||||||
|
document.getElementById('roFps').textContent = Math.round(frames * 1000 / (nowMs - fpsT));
|
||||||
|
document.getElementById('roPts').textContent = ptsThisFrame;
|
||||||
|
document.getElementById('roDecay').textContent = decay.toFixed(2);
|
||||||
|
frames = 0; fpsT = nowMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- graticule overlay
|
||||||
|
function drawGraticule() {
|
||||||
|
const g = document.getElementById('grat'), c = g.getContext('2d');
|
||||||
|
c.clearRect(0, 0, g.width, g.height);
|
||||||
|
if (!S.graticule) return;
|
||||||
|
const s = g.width / 10;
|
||||||
|
c.strokeStyle = 'rgba(150, 175, 160, 0.22)'; c.lineWidth = 1;
|
||||||
|
for (let i = 1; i < 10; i++) {
|
||||||
|
c.beginPath(); c.moveTo(i * s + .5, 0); c.lineTo(i * s + .5, g.height); c.stroke();
|
||||||
|
c.beginPath(); c.moveTo(0, i * s + .5); c.lineTo(g.width, i * s + .5); c.stroke();
|
||||||
|
}
|
||||||
|
c.strokeStyle = 'rgba(150, 175, 160, 0.32)';
|
||||||
|
for (let i = 1; i < 50; i++) {
|
||||||
|
const p = i * s / 5 + .5, m = g.width / 2;
|
||||||
|
c.beginPath(); c.moveTo(p, m - 4); c.lineTo(p, m + 4); c.stroke();
|
||||||
|
c.beginPath(); c.moveTo(m - 4, p); c.lineTo(m + 4, p); c.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- controls
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
function pressOnly(groupSel, btn) { document.querySelectorAll(groupSel + ' button').forEach(b => b.setAttribute('aria-pressed', String(b === btn))); }
|
||||||
|
|
||||||
|
document.querySelectorAll('#states .key').forEach(b => b.addEventListener('click', () => {
|
||||||
|
S.sequence = false; $('seqBtn').setAttribute('aria-pressed', 'false');
|
||||||
|
setState(b.dataset.state, performance.now() / 1000);
|
||||||
|
}));
|
||||||
|
function toggleSeq() {
|
||||||
|
S.sequence = !S.sequence; $('seqBtn').setAttribute('aria-pressed', String(S.sequence));
|
||||||
|
if (S.sequence) { seqIdx = 0; seqT0 = performance.now() / 1000; setState(SEQ[0][0], seqT0); }
|
||||||
|
}
|
||||||
|
$('seqBtn').addEventListener('click', toggleSeq);
|
||||||
|
|
||||||
|
document.querySelectorAll('#phosSeg button').forEach(b => b.addEventListener('click', () => {
|
||||||
|
S.phos = b.dataset.p; pressOnly('#phosSeg', b); $('phosNote').textContent = PHOSPHORS[S.phos].note;
|
||||||
|
}));
|
||||||
|
document.querySelectorAll('#fpsSeg button').forEach(b => b.addEventListener('click', () => { S.fps = +b.dataset.fps; pressOnly('#fpsSeg', b); }));
|
||||||
|
|
||||||
|
function bindRange(id, fn) { const el = $(id); const go = () => fn(+el.value); el.addEventListener('input', go); go(); }
|
||||||
|
bindRange('persist', v => { S.persistMs = v; $('persistOut').textContent = v + ' ms'; });
|
||||||
|
bindRange('inten', v => { S.intensity = v / 100; $('intenOut').textContent = (v / 100).toFixed(1) + '×'; });
|
||||||
|
bindRange('focus', v => { S.focus = v / 100; $('focusOut').textContent = v < 20 ? 'sharp' : v < 55 ? 'soft' : v < 80 ? 'bloomy' : 'smeared'; });
|
||||||
|
bindRange('jitter', v => { S.jitter = v / 10; $('jitterOut').textContent = (v / 10).toFixed(1) + ' px'; });
|
||||||
|
const sign = v => (v > 0 ? '+' : v < 0 ? '−' : '+') + Math.abs(v) + '°';
|
||||||
|
bindRange('pan', v => { S.pan = v; $('panOut').textContent = sign(v); });
|
||||||
|
bindRange('tilt', v => { S.tilt = v; $('tiltOut').textContent = sign(v); });
|
||||||
|
|
||||||
|
$('pixBtn').addEventListener('click', () => {
|
||||||
|
S.pixels = !S.pixels; $('pixBtn').setAttribute('aria-pressed', String(S.pixels));
|
||||||
|
$('screen').classList.toggle('pixels', S.pixels);
|
||||||
|
});
|
||||||
|
$('gratBtn').addEventListener('click', () => {
|
||||||
|
S.graticule = !S.graticule; $('gratBtn').setAttribute('aria-pressed', String(S.graticule)); drawGraticule();
|
||||||
|
});
|
||||||
|
|
||||||
|
const MODE_NOTE = {
|
||||||
|
flat: 'Flat face; only the eyes follow the servos.',
|
||||||
|
head: 'Smooth wireframe head that turns with the servos.',
|
||||||
|
lowpoly: 'Faceted head: a coarse mesh of straight edges, turning with the servos.',
|
||||||
|
};
|
||||||
|
const MODES = ['flat', 'head', 'lowpoly'];
|
||||||
|
let lowpolyCols = 0, meshCols = 0;
|
||||||
|
function densityLabel() {
|
||||||
|
const v = S.density;
|
||||||
|
$('densityOut').textContent = S.mode === 'lowpoly' ? Math.max(5, Math.round(v * 0.62)) + ' × ' + lowpolyCols : v + ' × ' + meshCols;
|
||||||
|
}
|
||||||
|
function setMode(m) {
|
||||||
|
S.mode = m;
|
||||||
|
pressOnly('#modeSeg', $('mode-' + m));
|
||||||
|
$('modeNote').textContent = MODE_NOTE[m];
|
||||||
|
$('meshKnobs').hidden = m === 'flat';
|
||||||
|
densityLabel();
|
||||||
|
}
|
||||||
|
function setStyle(s) {
|
||||||
|
S.style = s;
|
||||||
|
pressOnly('#styleSeg', $('style-' + s));
|
||||||
|
$('vectorNote').hidden = s !== 'vector';
|
||||||
|
buf.fill(0);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#modeSeg button').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
|
||||||
|
document.querySelectorAll('#styleSeg button').forEach(b => b.addEventListener('click', () => setStyle(b.dataset.style)));
|
||||||
|
bindRange('mesh', v => { S.mesh = v / 100; $('meshOut').textContent = v + '%'; });
|
||||||
|
bindRange('density', v => {
|
||||||
|
S.density = v;
|
||||||
|
meshCols = buildMesh(v);
|
||||||
|
lowpolyCols = buildLowPoly(Math.max(5, Math.round(v * 0.62)));
|
||||||
|
densityLabel();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.target.closest && e.target.closest('input')) return;
|
||||||
|
const n = parseInt(e.key, 10);
|
||||||
|
if (e.key === 'f' || e.key === 'F') { setMode(MODES[(MODES.indexOf(S.mode) + 1) % MODES.length]); return; }
|
||||||
|
if (e.key === 'v' || e.key === 'V') { setStyle(S.style === 'vector' ? 'phosphor' : 'vector'); return; }
|
||||||
|
if (n >= 1 && n <= 5) { $(['k-idle', 'k-listen', 'k-think', 'k-look', 'k-speak'][n - 1]).click(); }
|
||||||
|
else if (e.code === 'Space') { e.preventDefault(); toggleSeq(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!S.sequence) $('seqBtn').setAttribute('aria-pressed', 'false');
|
||||||
|
if (reduceMotion) { S.jitter = 0; $('jitter').value = 0; $('jitterOut').textContent = '0.0 px'; }
|
||||||
|
drawGraticule();
|
||||||
|
const t0 = performance.now() / 1000; seqT0 = t0; stateT0 = t0; nextBlink = t0 + 2.5;
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Hermes robot enclosure
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
An upright cabinet for the Raspberry Pi 4 and BrainCraft HAT. The display is the face, and the
|
||||||
|
speaker sits behind a grille below it. The whole cabinet pans on a bearing turntable, and a small
|
||||||
|
yoke on top tilts only the camera. Everything is in one parametric OpenSCAD file,
|
||||||
|
`hermes_robot.scad`. Ready-made STLs are in `stl/`, but **don't print the large parts until the test
|
||||||
|
pieces fit**: most dimensions were taken from photos and datasheets.
|
||||||
|
|
||||||
|
Overall size: about 86 × 40 × 146 mm for the cabinet, on a 90 mm base. Total height is about 225 mm
|
||||||
|
including the camera.
|
||||||
|
|
||||||
|
## Parts to buy
|
||||||
|
|
||||||
|
| Part | Notes |
|
||||||
|
|---|---|
|
||||||
|
| 2 × MG90S metal-gear micro servos | Pan (in the base) and tilt (camera). Use the double-arm horns they come with. |
|
||||||
|
| 6810-2RS ball bearing, 50 × 65 × 7 mm | The turntable. The 50 mm bore leaves room for the servo and cables. |
|
||||||
|
| Adafruit 5986 speaker (or the CQRobot 8 Ω JST-PH equivalent) | 70 × 30 × 17 mm box. |
|
||||||
|
| 30 × 30 × 10 mm 5 V fan | Optional; plugs into the HAT's fan header. Set `fan = false` to drop the mount. |
|
||||||
|
| Right-angle USB-C cable | The left side has about 13 mm beside the board for the plug. |
|
||||||
|
| M3 screws: 4 × 8 mm (back cover), 4 × 8 mm (turntable), 2 × 12 mm + nuts (yoke), 1 × 16 mm (tilt axle) + washer | Self-tapping into PETG where the model has 2.6 mm pilots. |
|
||||||
|
| 4 × M2 × 6 mm screws | Camera board to cradle. |
|
||||||
|
| 4 small rubber feet, 10 mm | Optional, in the base's recesses. |
|
||||||
|
|
||||||
|
The servos plug into the HAT's GPIO12 (pan) and GPIO13 (tilt) ports. Those ports are factory-set to
|
||||||
|
5 V (checked by continuity on the "3pin Volt" jumper), so no soldering is needed.
|
||||||
|
|
||||||
|
## Measure first
|
||||||
|
|
||||||
|
Every value marked `MEASURE` in the `.scad` file is an estimate. With calipers, check at least:
|
||||||
|
|
||||||
|
1. **The stack:** the gap between the Pi and HAT boards (`stack_gap`), the tallest part on the HAT's
|
||||||
|
front (`hat_clear`), and the screw-head size (`head_d`, `head_h`).
|
||||||
|
2. **HAT features:** the display glass position (`glass_px`, `glass_py`), the mics, the audio switch
|
||||||
|
and the LEDs. All are in Pi coordinates: `px` runs down the board from the microSD end, `py`
|
||||||
|
across it from the USB-C edge.
|
||||||
|
3. **The speaker box** (`spk`).
|
||||||
|
4. **The servo** (`srv_*`) and its **double-arm horn** (`horn_*`): arm length from shaft centre to
|
||||||
|
tip, widths at hub and tip, thickness, and the radii of its screw holes.
|
||||||
|
5. **The bearing** (`brg_*`), if yours isn't a 6810.
|
||||||
|
6. **The camera board** (`cam_*`): the hole positions and lens position.
|
||||||
|
|
||||||
|
Change a value, then re-export: `openscad -D 'PART="body"' -o stl/body.stl hermes_robot.scad`.
|
||||||
|
|
||||||
|
## Print the test pieces first
|
||||||
|
|
||||||
|
| Piece | What it checks | Rough time |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_front` | The front wall over the HAT. Press it onto the stack: the cups should seat on the screw heads, with the window over the display, the mic holes over the mics, the slot over the audio switch, and the thin LED spots over the LEDs. | ~40 min |
|
||||||
|
| `test_bearing` | The bearing seat in the base and the hub on the plate. The bearing should press in firmly by hand; adjust `press` if it's loose or won't go. | ~1 h |
|
||||||
|
| `test_servo` | The servo posts in the base: the screw holes should line up with the servo's tabs. | ~25 min |
|
||||||
|
| `test_yoke` | The tilt servo's mount plate: the case should slide through and the tabs line up. | ~15 min |
|
||||||
|
|
||||||
|
## Printing (Adventurer 5M Pro, PETG)
|
||||||
|
|
||||||
|
| Part | Orientation (as exported) | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `body` | Front face on the bed | No supports. The front face takes the plate's texture. |
|
||||||
|
| `back` | Outside face on the bed | No supports. |
|
||||||
|
| `base` | Upright | No supports. |
|
||||||
|
| `plate` | Top face on the bed, hub up | No supports. |
|
||||||
|
| `yoke` | Upright | No supports. |
|
||||||
|
| `cradle` | Horn flange on the bed | No supports. |
|
||||||
|
|
||||||
|
Suggested settings: 0.2 mm layers, 3 walls, 20% gyroid infill. Use 40% infill for `plate` and
|
||||||
|
`cradle`, which carry the servo horns. Print the bearing seat and hub slowly on their outer walls for
|
||||||
|
accuracy.
|
||||||
|
|
||||||
|
## Assembly
|
||||||
|
|
||||||
|
1. **Base:** set the pan servo on its posts, shaft up, and screw it through the tabs. Press the
|
||||||
|
bearing into the seat. Feed the USB-C cable in through the slot at the back of the base, up through
|
||||||
|
the bearing.
|
||||||
|
2. **Turntable:** screw a double-arm horn into the pocket under the plate. Centre the servo (run the
|
||||||
|
software's `turn_head(0, 0)`, or use a servo tester), press the plate's hub into the bearing so the
|
||||||
|
horn meets the spline, and fit the horn's centre screw through the hole in the plate.
|
||||||
|
3. **Body onto the plate:** pass the power cable and the pan servo's lead up through the floor hole.
|
||||||
|
Then screw the plate on from inside the body with 4 × M3, before the speaker goes in.
|
||||||
|
4. **Speaker:** route its lead up the left-hand channel, and set the box on its feet, facing the grille.
|
||||||
|
5. **Stack:** plug in the speaker, the servo leads and the fan. Lower the Pi and HAT stack face-first
|
||||||
|
onto the locating cups, then connect the right-angle USB-C plug on the left.
|
||||||
|
6. **Back cover:** its cups clamp the stack, and its bars hold the speaker against the grille. Use
|
||||||
|
4 × M3.
|
||||||
|
7. **Camera:** screw the board to the cradle (M2). Fit the second horn into the flange pocket. Centre
|
||||||
|
the tilt servo, mount it in the yoke, push the flange onto its spline, and add the M3 axle on the
|
||||||
|
other side. Feed the ribbon down through the slot. Bolt the yoke to the top with 2 × M3 and nuts
|
||||||
|
from inside.
|
||||||
|
|
||||||
|
The camera ribbon drops straight from the cradle into the gap between the Pi and the HAT, then down
|
||||||
|
to the camera connector. A 15–20 cm ribbon should be enough. Leave a gentle loop above the slot for
|
||||||
|
the tilt.
|
||||||
|
|
||||||
|
## Known compromises
|
||||||
|
|
||||||
|
- The tilt servo hangs about 5 mm past the left side of the cabinet. Set `cam_x_offset = 5` to move
|
||||||
|
the camera and yoke right and bring it flush, at the cost of an off-centre camera.
|
||||||
|
- The front's controls (joystick, button) aren't exposed. They sit about 7 mm behind the front wall.
|
||||||
|
The LEDs glow through 0.6 mm of PETG, so light-coloured filament works best for that.
|
||||||
|
- There's no hard stop on the pan. Software limits (±80°) and the slack in the power cable are what
|
||||||
|
keep it from over-rotating.
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
// Hermes robot enclosure — Raspberry Pi 4 + Adafruit BrainCraft HAT
|
||||||
|
//
|
||||||
|
// Layout: an upright cabinet holding the Pi/HAT stack portrait-wise, the display as the face,
|
||||||
|
// a speaker behind a grille below it, all turning on a bearing turntable (pan). A small yoke on
|
||||||
|
// top tilts only the camera.
|
||||||
|
//
|
||||||
|
// Coordinates (body): X = width, viewer's left to right; Y = depth, front face at Y=0 going back;
|
||||||
|
// Z = height, up. Pi coordinates: px along the board's length from the microSD end (0..85),
|
||||||
|
// py across it from the USB-C edge (0..56). The stack stands with px pointing down, py to the right.
|
||||||
|
//
|
||||||
|
// EVERY value marked MEASURE was estimated from photos or datasheets. Check it with calipers and
|
||||||
|
// print the test pieces (PART = "test_*") before printing the big parts.
|
||||||
|
//
|
||||||
|
// Export one part: openscad -D 'PART="body"' -o body.stl hermes_robot.scad
|
||||||
|
|
||||||
|
/* [Part] */
|
||||||
|
PART = "assembly"; // [assembly, exploded, body, back, plate, base, yoke, cradle, test_front, test_bearing, test_servo, test_yoke]
|
||||||
|
|
||||||
|
$fn = 72;
|
||||||
|
eps = 0.01;
|
||||||
|
wall = 2.4; // walls and front face
|
||||||
|
tol = 0.3; // general sliding clearance
|
||||||
|
|
||||||
|
// ============================================================ MEASURE: Pi 4 + BrainCraft stack
|
||||||
|
pi_len = 85; pi_wid = 56; pcb = 1.6;
|
||||||
|
pi_holes = [[3.5,3.5],[61.5,3.5],[3.5,52.5],[61.5,52.5]]; // [px, py], same on Pi and HAT
|
||||||
|
port_over = 2.5; // USB/Ethernet past the board end
|
||||||
|
stack_gap = 11; // Pi top surface to HAT underside (standoff length) MEASURE
|
||||||
|
hat_clear = 6.5; // tallest part on the HAT front, display excluded MEASURE
|
||||||
|
pi_back_clear = 3; // solder pins / screw heads under the Pi MEASURE
|
||||||
|
head_d = 5.6; head_h = 2.2; // M2.5 screw heads holding the stack MEASURE
|
||||||
|
|
||||||
|
// HAT front features, in Pi coordinates [px, py] MEASURE ALL
|
||||||
|
glass_px = [6.9, 40.7]; glass_py = [10.9, 44.7]; // display glass extents
|
||||||
|
win_w = 28.6; win_h = 28.6; win_r = 1.5; // visible window (active area 27.7 sq)
|
||||||
|
win_shift = [0, 0]; // nudge window [dpx, dpy]
|
||||||
|
mic_l = [4.9, 21.7]; mic_r = [62.2, 20.2];
|
||||||
|
sw_c = [5.5, 40.0]; sw_len = 9; sw_wid = 4.5; // audio switch slot (length along py)
|
||||||
|
leds = [[44.6,41.8],[53.4,41.8],[61.8,41.8]];
|
||||||
|
soc = [29, 32]; // Pi SoC, for the fan
|
||||||
|
sd_py = 28; // microSD centre
|
||||||
|
|
||||||
|
// ============================================================ MEASURE: speaker (Adafruit 5986 box)
|
||||||
|
spk = [70, 17.2, 30.2]; // X width, Y depth, Z height
|
||||||
|
|
||||||
|
// ============================================================ MEASURE: MG90S servo + double-arm horn
|
||||||
|
srv_l = 22.8; srv_w = 12.2; // case length (along tabs) and width
|
||||||
|
srv_tab_z = 16.0; srv_tab_t = 2.5; srv_tab_len = 32.2; srv_hole_sp = 27.6;
|
||||||
|
srv_shaft_off = 5.9; // shaft centre from the near end of the case
|
||||||
|
srv_tip_h = 32.0; // case bottom to spline tip
|
||||||
|
srv_bump_h = 26.0; // case bottom to top of the gear bump
|
||||||
|
horn_len = 16; // shaft centre to arm tip
|
||||||
|
horn_hub_w = 7.5; horn_tip_w = 4.2; horn_t = 1.8; // arm widths and thickness
|
||||||
|
horn_seat = 0.5; // spline tip sits this far below the horn's top face
|
||||||
|
horn_holes = [9, 13]; // radii of the screw holes along each arm
|
||||||
|
|
||||||
|
// ============================================================ MEASURE: bearing 6810-2RS (50 x 65 x 7)
|
||||||
|
brg_id = 50; brg_od = 65; brg_h = 7;
|
||||||
|
brg_in_shoulder = 55; // inner ring outer diameter (plate shoulder must stay inside)
|
||||||
|
brg_out_shoulder = 59.5; // outer ring inner diameter
|
||||||
|
press = 0.15; // press-fit interference; tune with test_bearing
|
||||||
|
|
||||||
|
// ============================================================ MEASURE: camera (Arducam UC-346 / Pi cam v1 size)
|
||||||
|
cam = [25, 24]; cam_pcb = 1.0;
|
||||||
|
cam_holes = [[2,2],[23,2],[2,14.5],[23,14.5]]; // from top-left, x right, y down
|
||||||
|
cam_lens_top = 9.46; // lens centre below the board's top edge
|
||||||
|
cam_conn_h = 6; // ribbon connector band at the bottom of the board's back
|
||||||
|
cam_standoff = 3; // board back to cradle plate
|
||||||
|
|
||||||
|
// ============================================================ layout choices
|
||||||
|
W = 86; // body width
|
||||||
|
top_clear = 11; // above the board end (JST overhang + plug)
|
||||||
|
plenum = 8; // cable space under the speaker
|
||||||
|
shelf_t = 2;
|
||||||
|
rear_air = 11; // behind the Pi: airflow and a 30 x 30 x 10 mm fan
|
||||||
|
R_out = 7; // corner radius seen from the front
|
||||||
|
label = "HERMES"; // engraved under the grille; "" for none
|
||||||
|
fan = true; // 30 mm fan mount in the back cover
|
||||||
|
fan_sz = 30; fan_hole_sp = 24;
|
||||||
|
plate_d = 76; plate_t = 4; gap_turn = 0.8;
|
||||||
|
tilt_axis_h = 26; // tilt axis above the body top (horn flange must clear the yoke base)
|
||||||
|
cam_x_offset = 0; // shift camera + yoke sideways if the servo overhang bothers you
|
||||||
|
|
||||||
|
// ============================================================ derived
|
||||||
|
disp_px_c = (glass_px[0]+glass_px[1])/2 + win_shift[0];
|
||||||
|
disp_py_c = (glass_py[0]+glass_py[1])/2 + win_shift[1];
|
||||||
|
x0 = W/2 - disp_py_c; // body X of the board's py=0 edge
|
||||||
|
z_spk0 = wall + plenum;
|
||||||
|
z_shelf = z_spk0 + spk[2] + 0.6;
|
||||||
|
z_pi_end = z_shelf + shelf_t + 2;
|
||||||
|
z_top_board = z_pi_end + port_over + pi_len; // body Z of px=0
|
||||||
|
H = z_top_board + top_clear + wall;
|
||||||
|
y_in = wall;
|
||||||
|
y_hat_f = y_in + 0.5 + hat_clear;
|
||||||
|
y_hat_b = y_hat_f + pcb;
|
||||||
|
y_pi_t = y_hat_b + stack_gap;
|
||||||
|
y_pi_b = y_pi_t + pcb;
|
||||||
|
y_back_in = y_pi_b + pi_back_clear + rear_air;
|
||||||
|
D = y_back_in + wall;
|
||||||
|
axis = [W/2, D/2]; // pan axis in body XY
|
||||||
|
spk_x1 = W - wall - 0.6; spk_x0 = spk_x1 - spk[0];
|
||||||
|
spk_y0 = y_in + 0.5;
|
||||||
|
|
||||||
|
function BX(py) = x0 + py;
|
||||||
|
function BZ(px) = z_top_board - px;
|
||||||
|
|
||||||
|
echo(str("BODY W=", W, " D=", D, " H=", H));
|
||||||
|
echo(str("left clearance beside board = ", x0 - wall, " mm (USB-C plug side)"));
|
||||||
|
echo(str("speaker cable channel = ", spk_x0 - wall, " mm"));
|
||||||
|
assert(x0 - wall >= 11, "not enough room for a right-angle USB-C plug on the left");
|
||||||
|
|
||||||
|
// servo geometry along its own axis (0 = case bottom, + toward the spline tip)
|
||||||
|
srv_case_c = srv_l/2 - srv_shaft_off; // case centre relative to shaft, along its length
|
||||||
|
|
||||||
|
// base stack: everything follows from where the spline tip must be
|
||||||
|
base_floor = 2.4;
|
||||||
|
z_srv_bottom = base_floor + 1.5;
|
||||||
|
base_h = z_srv_bottom + srv_tip_h - gap_turn - horn_t + horn_seat; // bearing top flush with base top
|
||||||
|
z_brg0 = base_h - brg_h;
|
||||||
|
echo(str("BASE height=", base_h));
|
||||||
|
|
||||||
|
// tilt yoke geometry (local: origin on tilt axis at cradle centre, X along axis, camera looks -Y)
|
||||||
|
cb_r = 17; // right boss outer face
|
||||||
|
cb_l = 13.6; // left flange inner face (just clear of the camera board)
|
||||||
|
fl_t = horn_t + 1.6; // left flange thickness
|
||||||
|
x_tip = -cb_l - fl_t + horn_t - horn_seat; // tilt servo spline tip
|
||||||
|
x_tabtop = x_tip - (srv_tip_h - srv_tab_z - srv_tab_t);
|
||||||
|
x_srv_bottom = x_tip - srv_tip_h;
|
||||||
|
y_tilt = 10.5; // tilt axis in body Y
|
||||||
|
mp_t = 6; // servo mount plate thickness
|
||||||
|
yb_t = 4; // yoke base plate thickness
|
||||||
|
yoke_y = [-10.5, 22.5];
|
||||||
|
yoke_bolts = [[-15, 20], [17, 20]]; // local [X, Y]
|
||||||
|
echo(str("tilt servo bottom overhangs body side by ", -(W/2 + cam_x_offset + x_srv_bottom), " mm"));
|
||||||
|
|
||||||
|
// ============================================================ helpers
|
||||||
|
module rrect(w, h, r) {
|
||||||
|
r2 = max(0.01, min(r, w/2 - 0.01, h/2 - 0.01));
|
||||||
|
hull() for (x = [r2, w - r2], y = [r2, h - r2]) translate([x, y]) circle(r = r2);
|
||||||
|
}
|
||||||
|
// rounded rectangle in the XZ plane at corner (x, z), spanning y0..y1
|
||||||
|
module xz_rr(x, z, w, h, r, y0, y1) {
|
||||||
|
translate([x, y1, z]) rotate([90, 0, 0]) linear_extrude(y1 - y0) rrect(w, h, r);
|
||||||
|
}
|
||||||
|
module xz_rr_c(cx, cz, w, h, r, y0, y1) xz_rr(cx - w/2, cz - h/2, w, h, r, y0, y1);
|
||||||
|
// cylinder along +Y
|
||||||
|
module ycyl(x, y0, z, d, len) translate([x, y0, z]) rotate([-90, 0, 0]) cylinder(d = d, h = len);
|
||||||
|
// through the front wall, with a 45 degree chamfer on the outside
|
||||||
|
module front_cut(cx, cz, w, h, r, ch) {
|
||||||
|
xz_rr_c(cx, cz, w, h, r, -1, wall + 1);
|
||||||
|
if (ch > 0) hull() {
|
||||||
|
xz_rr_c(cx, cz, w + 2*ch, h + 2*ch, r + ch, -1, 0);
|
||||||
|
xz_rr_c(cx, cz, w, h, r, ch, ch + eps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ BODY SHELL (print front face down)
|
||||||
|
module boss_corner(xc, zc, side) { // screw boss in a corner, 45 degree underside for printing
|
||||||
|
bs = 7; L = 10;
|
||||||
|
x = side < 0 ? wall : W - wall - bs;
|
||||||
|
wx = side < 0 ? wall : W - wall - eps;
|
||||||
|
hull() {
|
||||||
|
translate([x, y_back_in - L, zc - bs/2]) cube([bs, L, bs]);
|
||||||
|
translate([wx, y_back_in - L - bs, zc - bs/2]) cube([eps, eps, bs]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boss_z = [H - wall - 3.5, z_shelf + shelf_t + 3.5];
|
||||||
|
function boss_pts() = [for (z = boss_z, s = [-1, 1]) [s < 0 ? wall + 3.5 : W - wall - 3.5, z, s]];
|
||||||
|
plate_screws = [[-31,-10],[31,-10],[-31,10],[31,10]]; // relative to the pan axis
|
||||||
|
|
||||||
|
module body() {
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
difference() {
|
||||||
|
xz_rr(0, 0, W, H, R_out, 0, D);
|
||||||
|
xz_rr(wall, wall, W - 2*wall, H - 2*wall, R_out - wall, y_in, D + 1);
|
||||||
|
}
|
||||||
|
// shelf over the speaker; the left strip stays open as the cable channel
|
||||||
|
translate([spk_x0, y_in - eps, z_shelf]) cube([W - wall - spk_x0 + eps, y_back_in - y_in, shelf_t]);
|
||||||
|
// speaker feet
|
||||||
|
for (x = [spk_x0 + 12, spk_x1 - 17], y = [spk_y0 + 1, spk_y0 + spk[1] - 6])
|
||||||
|
translate([x, y, wall - eps]) cube([5, 5, plenum + eps]);
|
||||||
|
// locating cups for the HAT's front screw heads
|
||||||
|
for (h = pi_holes) difference() {
|
||||||
|
ycyl(BX(h[1]), y_in - 0.5, BZ(h[0]), 7, (y_hat_f - 0.4) - (y_in - 0.5));
|
||||||
|
ycyl(BX(h[1]), y_hat_f - head_h, BZ(h[0]), head_d + 0.4, 5);
|
||||||
|
}
|
||||||
|
for (b = boss_pts()) boss_corner(b[0], b[1], b[2]);
|
||||||
|
// ledge that stops the back cover at the floor
|
||||||
|
translate([wall + R_out, y_back_in - 2.5, wall - eps]) cube([W - 2*wall - 2*R_out, 2.5, 2.5]);
|
||||||
|
}
|
||||||
|
// ---- front face
|
||||||
|
cx = BX(disp_py_c); cz = BZ(disp_px_c);
|
||||||
|
front_cut(cx, cz, win_w, win_h, win_r, 1.8); // deep chamfer: a recessed "tube face"
|
||||||
|
for (m = [mic_l, mic_r]) {
|
||||||
|
ycyl(BX(m[1]), -1, BZ(m[0]), 2.0, wall + 2);
|
||||||
|
translate([BX(m[1]), -eps, BZ(m[0])]) rotate([-90, 0, 0]) cylinder(d1 = 3.6, d2 = 2.0, h = 0.8);
|
||||||
|
}
|
||||||
|
front_cut(BX(sw_c[1]), BZ(sw_c[0]), sw_len, sw_wid, 1.2, 0.6);
|
||||||
|
for (l = leds) ycyl(BX(l[1]), 0.6, BZ(l[0]), 5, wall); // thin skin: LEDs glow through PETG
|
||||||
|
// speaker grille
|
||||||
|
gcx = (spk_x0 + spk_x1)/2; gcz = z_spk0 + spk[2]/2;
|
||||||
|
for (i = [-2.5 : 1 : 2.5]) front_cut(gcx, gcz + i*4.2, 54, 2.2, 1.1, 0.6);
|
||||||
|
if (label != "")
|
||||||
|
translate([gcx, 0.6, wall + plenum/2 + 0.3]) rotate([90, 0, 0]) linear_extrude(2)
|
||||||
|
text(label, size = 4.2, halign = "center", valign = "center", font = "Liberation Sans:style=Bold", spacing = 1.25);
|
||||||
|
// ---- top
|
||||||
|
tx = axis[0] + cam_x_offset;
|
||||||
|
translate([tx - 9.5, y_tilt + 0.5, H - wall - 1]) cube([19, 3.5, wall + 2]); // camera ribbon, drops into the Pi-HAT gap
|
||||||
|
translate([max(R_out + 5, tx + x_srv_bottom + 7), y_tilt + 18, H - wall - 1]) cylinder(d = 7, h = wall + 2); // tilt servo lead
|
||||||
|
for (b = yoke_bolts) translate([tx + b[0], y_tilt + b[1], H - wall - 1]) cylinder(d = 3.4, h = wall + 2);
|
||||||
|
translate([BX(sd_py) - 8, y_pi_b - 1, H - wall - 1]) cube([16, 5, wall + 2]); // microSD access
|
||||||
|
// ---- sides: vents high and low
|
||||||
|
for (s = [0, 1], zc = [z_top_board - 14, z_pi_end + 14], i = [0 : 3])
|
||||||
|
translate([s ? W - wall - 1 : -1, y_hat_b + 1 + i*5.5, zc - 9]) cube([wall + 2, 2.2, 18]);
|
||||||
|
// ---- floor: cable hole and turntable plate screws
|
||||||
|
translate([axis[0], axis[1], -1]) cylinder(d = 26, h = wall + 2);
|
||||||
|
for (p = plate_screws) translate([axis[0] + p[0], axis[1] + p[1], -1]) cylinder(d = 3.4, h = wall + 2);
|
||||||
|
// ---- back screw pilots
|
||||||
|
for (b = boss_pts()) translate([b[0], y_back_in - 9, b[1]]) rotate([-90, 0, 0]) cylinder(d = 2.6, h = 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ BACK COVER (print flat, outside down)
|
||||||
|
module back() {
|
||||||
|
cw = W - 2*wall - 2*tol; ch = H - 2*wall - 2*tol;
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
xz_rr(wall + tol, wall + tol, cw, ch, R_out - wall - tol, y_back_in, D);
|
||||||
|
// clamp the stack: cups press the Pi's rear screw heads
|
||||||
|
for (h = pi_holes) difference() {
|
||||||
|
ycyl(BX(h[1]), y_pi_b + 0.4, BZ(h[0]), 7, y_back_in - (y_pi_b + 0.4) + eps);
|
||||||
|
ycyl(BX(h[1]), y_pi_b - 1, BZ(h[0]), head_d + 0.4, head_h + 1);
|
||||||
|
}
|
||||||
|
// two bars hold the speaker against the front wall
|
||||||
|
for (x = [spk_x0 + 8, spk_x1 - 14])
|
||||||
|
translate([x, spk_y0 + spk[1] + 0.2, z_spk0 + 6]) cube([6, y_back_in - (spk_y0 + spk[1] + 0.2) + eps, spk[2] - 12]);
|
||||||
|
}
|
||||||
|
for (b = boss_pts()) { // countersunk M3
|
||||||
|
translate([b[0], y_back_in - 1, b[1]]) rotate([-90, 0, 0]) cylinder(d = 3.4, h = wall + 2);
|
||||||
|
translate([b[0], D - 1.8 + eps, b[1]]) rotate([-90, 0, 0]) cylinder(d1 = 3.4, d2 = 6.6, h = 1.8);
|
||||||
|
}
|
||||||
|
fx = BX(soc[1]); fz = BZ(soc[0]);
|
||||||
|
if (fan) {
|
||||||
|
ycyl(fx, y_back_in - 1, fz, fan_sz - 2, wall + 2);
|
||||||
|
for (i = [-1, 1], j = [-1, 1]) ycyl(fx + i*fan_hole_sp/2, y_back_in - 1, fz + j*fan_hole_sp/2, 3.0, wall + 2);
|
||||||
|
}
|
||||||
|
// intake slots low over the Pi's port end and the speaker shelf; exhaust high
|
||||||
|
for (zc = [z_pi_end + 10, z_top_board - 6], i = [-3 : 3])
|
||||||
|
if (!fan || abs(zc - fz) > fan_sz/2 + 4)
|
||||||
|
xz_rr_c(W/2 + i*7.5, zc, 2.6, 14, 1.3, y_back_in - 1, D + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ TURNTABLE PLATE (print top face down)
|
||||||
|
// local: pan axis at origin, z = 0 is the plate's top (screwed under the body floor)
|
||||||
|
module horn_2d(extra = 0) {
|
||||||
|
hull() { circle(d = horn_hub_w + 2*extra); translate([0, horn_len]) circle(d = horn_tip_w + 2*extra); }
|
||||||
|
hull() { circle(d = horn_hub_w + 2*extra); translate([0, -horn_len]) circle(d = horn_tip_w + 2*extra); }
|
||||||
|
}
|
||||||
|
module plate() {
|
||||||
|
hub_od = brg_id - press;
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
difference() {
|
||||||
|
translate([0, 0, -plate_t]) cylinder(d = plate_d, h = plate_t);
|
||||||
|
translate([0, 0, -plate_t - 1]) cylinder(d = hub_od - 6, h = plate_t + 2);
|
||||||
|
}
|
||||||
|
// bridge across the bore along Y carries the servo horn
|
||||||
|
intersection() {
|
||||||
|
translate([0, 0, -plate_t]) cylinder(d = hub_od - 1, h = plate_t);
|
||||||
|
translate([0, 0, -plate_t]) linear_extrude(plate_t) union() {
|
||||||
|
offset(r = 2.5) horn_2d();
|
||||||
|
square([horn_tip_w + 5, brg_id], center = true); // spokes reach the ring
|
||||||
|
}
|
||||||
|
}
|
||||||
|
translate([0, 0, -plate_t - gap_turn]) difference() { // shoulder on the inner ring
|
||||||
|
cylinder(d = brg_in_shoulder - 1, h = gap_turn + eps);
|
||||||
|
translate([0, 0, -1]) cylinder(d = hub_od - 6, h = gap_turn + 2);
|
||||||
|
}
|
||||||
|
translate([0, 0, -plate_t - gap_turn - brg_h]) difference() { // hub pressed into the bore
|
||||||
|
cylinder(d = hub_od, h = brg_h + eps);
|
||||||
|
translate([0, 0, -1]) cylinder(d = hub_od - 6, h = brg_h + 2);
|
||||||
|
cylinder(d1 = hub_od - 1.2, d2 = hub_od - 6, h = 1.2); // lead-in
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// horn pocket and screw holes
|
||||||
|
translate([0, 0, -plate_t - eps]) linear_extrude(horn_t + 0.2) offset(r = 0.2) horn_2d();
|
||||||
|
translate([0, 0, -plate_t - 1]) cylinder(d = 6, h = plate_t + 2); // horn centre screw
|
||||||
|
for (r = horn_holes, s = [-1, 1]) translate([0, s*r, -plate_t - 1]) cylinder(d = 1.5, h = plate_t + 2);
|
||||||
|
// blind pilots for the body screws (driven from inside the body)
|
||||||
|
for (p = plate_screws) translate([p[0], p[1], -plate_t + 0.6]) cylinder(d = 2.6, h = plate_t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ BASE (print upright)
|
||||||
|
// local: pan axis at origin, z = 0 on the table
|
||||||
|
module base() {
|
||||||
|
r_top = brg_od/2 + 5; r_bot = 45;
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
cylinder(r1 = r_bot, r2 = r_bot, h = 3);
|
||||||
|
translate([0, 0, 3]) cylinder(r1 = r_bot, r2 = r_top, h = 9);
|
||||||
|
translate([0, 0, 12 - eps]) cylinder(r = r_top, h = base_h - 12 + eps);
|
||||||
|
}
|
||||||
|
translate([0, 0, base_floor]) cylinder(r = brg_out_shoulder/2 + 0.3, h = base_h); // cavity
|
||||||
|
translate([0, 0, z_brg0]) cylinder(d = brg_od + 0.1, h = brg_h + 1); // bearing seat
|
||||||
|
translate([0, 0, base_h - 0.6]) cylinder(d1 = brg_od + 0.1, d2 = brg_od + 1.3, h = 0.61); // lead-in
|
||||||
|
translate([-8, 25, base_floor]) cube([16, 30, 10]); // cable exit, back
|
||||||
|
for (a = [45 : 90 : 315]) rotate(a) translate([37, 0, -eps]) cylinder(d = 10.5, h = 1.2); // feet
|
||||||
|
}
|
||||||
|
// servo posts under the tabs (servo length along X, shaft on the axis)
|
||||||
|
for (hx = [srv_case_c - srv_hole_sp/2, srv_case_c + srv_hole_sp/2]) {
|
||||||
|
pw = (srv_tab_len - srv_l)/2 - 0.6;
|
||||||
|
px = hx < srv_case_c ? srv_case_c - srv_l/2 - 0.3 - pw : srv_case_c + srv_l/2 + 0.3;
|
||||||
|
difference() {
|
||||||
|
translate([px, -5, base_floor - eps]) cube([pw, 10, z_srv_bottom + srv_tab_z - base_floor + eps]);
|
||||||
|
translate([hx, 0, z_srv_bottom + srv_tab_z - 8]) cylinder(d = 1.8, h = 9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ TILT YOKE (print upright)
|
||||||
|
// local: tilt axis = X axis, origin at cradle centre; base plate underside at z = -tilt_axis_h
|
||||||
|
module yoke() {
|
||||||
|
zb = -tilt_axis_h;
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
// base plate
|
||||||
|
translate([x_tabtop, yoke_y[0], zb]) cube([cb_r + 7 - x_tabtop, yoke_y[1] - yoke_y[0], yb_t]);
|
||||||
|
// servo mount plate: tabs bear on its outer (-X) face
|
||||||
|
translate([x_tabtop, srv_case_c - srv_tab_len/2 - 2.5, zb]) cube([mp_t, srv_tab_len + 5, tilt_axis_h + srv_w/2 + 3]);
|
||||||
|
// right upright carrying the M3 axle
|
||||||
|
hull() {
|
||||||
|
translate([cb_r + 1, -7, zb]) cube([6, 14, yb_t]);
|
||||||
|
translate([cb_r + 1, 0, 0]) rotate([0, 90, 0]) cylinder(d = 12, h = 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// servo case through the mount plate
|
||||||
|
translate([x_tabtop - 1, srv_case_c - srv_l/2 - 0.3, -srv_w/2 - 0.3]) cube([mp_t + 2, srv_l + 0.6, srv_w + 0.6]);
|
||||||
|
for (s = [-1, 1]) translate([x_tabtop - 1, srv_case_c + s*srv_hole_sp/2, 0]) rotate([0, 90, 0]) cylinder(d = 1.8, h = mp_t + 2);
|
||||||
|
// axle
|
||||||
|
translate([cb_r - 1, 0, 0]) rotate([0, 90, 0]) cylinder(d = 3.3, h = 10);
|
||||||
|
// ribbon slot, microSD access, bolts
|
||||||
|
translate([-9.5, 0.5, zb - 1]) cube([19, 3.5, yb_t + 2]);
|
||||||
|
translate([BX(sd_py) - axis[0] - cam_x_offset - 8, y_pi_b - 1 - y_tilt, zb - 1]) cube([16, 5, yb_t + 2]);
|
||||||
|
for (b = yoke_bolts) translate([b[0], b[1], zb - 1]) cylinder(d = 3.4, h = yb_t + 2);
|
||||||
|
// lighten
|
||||||
|
translate([-4, 6, zb - 1]) cube([12, 10, yb_t + 2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ CAMERA CRADLE (print flat, plate down)
|
||||||
|
module cradle() {
|
||||||
|
pz0 = -cam_lens_top - (cam[1] - cam_lens_top) + cam_conn_h + 0.5; // plate bottom (clears connector)
|
||||||
|
pz1 = cam_lens_top + 2;
|
||||||
|
py0 = cam_standoff - 1.5; pt = 3; // plate in Y
|
||||||
|
difference() {
|
||||||
|
union() {
|
||||||
|
translate([-cam[0]/2 - 2, py0, pz0]) cube([cam[0] + 4, pt, pz1 - pz0]);
|
||||||
|
for (h = cam_holes) translate([h[0] - cam[0]/2, py0 + eps, cam_lens_top - h[1]]) rotate([90, 0, 0]) cylinder(d = 4.6, h = cam_standoff);
|
||||||
|
// right boss to the axle
|
||||||
|
hull() {
|
||||||
|
translate([cam[0]/2 + 2 - eps, py0, -4.5]) cube([eps, pt, 9]);
|
||||||
|
translate([cb_r - 4, 0, 0]) rotate([0, 90, 0]) cylinder(d = 9, h = 4);
|
||||||
|
}
|
||||||
|
// left: boss to the horn flange
|
||||||
|
hull() {
|
||||||
|
translate([-cam[0]/2 - 2, py0, -4.5]) cube([eps, pt, 9]);
|
||||||
|
translate([-cb_l, 0, 0]) rotate([0, 90, 0]) cylinder(d = 9, h = 1);
|
||||||
|
}
|
||||||
|
translate([-cb_l - fl_t, 0, 0]) rotate([0, 90, 0]) linear_extrude(fl_t) rotate(90) offset(r = 2.5) horn_2d();
|
||||||
|
}
|
||||||
|
for (h = cam_holes) translate([h[0] - cam[0]/2, py0 + pt + 1, cam_lens_top - h[1]]) rotate([90, 0, 0]) cylinder(d = 1.7, h = pt + cam_standoff + 2);
|
||||||
|
translate([-cb_l - fl_t - eps, 0, 0]) rotate([0, 90, 0]) linear_extrude(horn_t + 0.2) rotate(90) offset(r = 0.2) horn_2d();
|
||||||
|
translate([-cb_l - fl_t - 1, 0, 0]) rotate([0, 90, 0]) cylinder(d = 6, h = fl_t + 2);
|
||||||
|
for (r = horn_holes, s = [-1, 1]) translate([-cb_l - fl_t - 1, 0, s*r]) rotate([0, 90, 0]) cylinder(d = 1.5, h = fl_t + 2);
|
||||||
|
translate([cb_r + eps, 0, 0]) rotate([0, -90, 0]) cylinder(d = 2.6, h = 9); // M3 axle pilot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ dummy hardware for the assembly view
|
||||||
|
module dummy_stack() {
|
||||||
|
color("darkgreen") translate([BX(0), y_pi_t, BZ(pi_len)]) cube([pi_wid, pcb, pi_len]);
|
||||||
|
color("silver") translate([BX(10), y_pi_t - 16, BZ(pi_len + port_over)]) cube([36, 16, 20]);
|
||||||
|
color("black") translate([BX(0), y_hat_f, BZ(65)]) cube([pi_wid, pcb, 65]);
|
||||||
|
color("dimgray") translate([BX(glass_py[0]), y_hat_f - 4, BZ(glass_px[1])]) cube([glass_py[1]-glass_py[0], 4, glass_px[1]-glass_px[0]]);
|
||||||
|
color("gold") for (h = pi_holes) ycyl(BX(h[1]), y_hat_f - head_h, BZ(h[0]), head_d, head_h);
|
||||||
|
}
|
||||||
|
module dummy_speaker() color("dimgray") translate([spk_x0, spk_y0, z_spk0]) cube(spk);
|
||||||
|
module dummy_servo() color("royalblue") { // standing: shaft up at origin
|
||||||
|
translate([srv_case_c - srv_l/2, -srv_w/2, -srv_tip_h]) cube([srv_l, srv_w, srv_bump_h - 3]);
|
||||||
|
translate([srv_case_c - srv_tab_len/2, -srv_w/2, -srv_tip_h + srv_tab_z]) cube([srv_tab_len, srv_w, srv_tab_t]);
|
||||||
|
translate([0, 0, -srv_tip_h]) cylinder(d = 11.5, h = srv_bump_h);
|
||||||
|
translate([0, 0, -srv_tip_h]) cylinder(d = 4.8, h = srv_tip_h);
|
||||||
|
}
|
||||||
|
module dummy_bearing() color("lightsteelblue") difference() { cylinder(d = brg_od, h = brg_h); translate([0,0,-1]) cylinder(d = brg_id, h = brg_h + 2); }
|
||||||
|
module dummy_camera() color("forestgreen") {
|
||||||
|
translate([-cam[0]/2, -cam_standoff + 1.5 - cam_pcb, cam_lens_top - cam[1]]) cube([cam[0], cam_pcb, cam[1]]);
|
||||||
|
translate([-4, -cam_standoff + 1.5 - cam_pcb - 5, -4]) cube([8, 5, 8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ placement
|
||||||
|
z_body0 = base_h + gap_turn + plate_t; // body bottom above the table
|
||||||
|
module placed(exploded = 0) {
|
||||||
|
e = exploded;
|
||||||
|
translate([-axis[0], -axis[1], 0]) {
|
||||||
|
translate([axis[0], axis[1], 0]) base();
|
||||||
|
translate([axis[0], axis[1], z_brg0]) dummy_bearing();
|
||||||
|
translate([axis[0], axis[1], base_h + gap_turn + horn_t - horn_seat]) dummy_servo();
|
||||||
|
translate([axis[0], axis[1], z_body0 + e*0.6]) plate();
|
||||||
|
translate([0, 0, z_body0 + e]) {
|
||||||
|
color("tan", 0.55) body();
|
||||||
|
color("burlywood") translate([0, e*1.2, 0]) back();
|
||||||
|
dummy_stack(); dummy_speaker();
|
||||||
|
translate([axis[0] + cam_x_offset, y_tilt, H + tilt_axis_h + e*0.6]) {
|
||||||
|
color("peru") yoke();
|
||||||
|
color("sandybrown") translate([0, 0, e*0.3]) cradle();
|
||||||
|
translate([0, 0, e*0.3]) dummy_camera();
|
||||||
|
translate([x_tip, 0, 0]) rotate([0, 90, 0]) rotate([0, 0, 90]) dummy_servo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ beauty render (opaque, display keyed in magenta)
|
||||||
|
module beauty() {
|
||||||
|
translate([-axis[0], -axis[1], 0]) {
|
||||||
|
color([0.20, 0.21, 0.23]) translate([axis[0], axis[1], 0]) base();
|
||||||
|
color([0.30, 0.31, 0.33]) translate([axis[0], axis[1], z_body0]) plate();
|
||||||
|
translate([0, 0, z_body0]) {
|
||||||
|
color([0.50, 0.50, 0.49]) body();
|
||||||
|
color([1, 0, 1]) translate([BX(glass_py[0]) - 3, y_in + 0.6, BZ(glass_px[1]) - 3])
|
||||||
|
cube([glass_py[1] - glass_py[0] + 6, 0.5, glass_px[1] - glass_px[0] + 6]);
|
||||||
|
color([0.05, 0.05, 0.06]) translate([wall + 1, y_in + 2, wall + 1]) cube([W - 2*wall - 2, 2, H - 2*wall - 2]); // dark interior behind slots
|
||||||
|
translate([axis[0] + cam_x_offset, y_tilt, H + tilt_axis_h]) rotate([8, 0, 0]) {
|
||||||
|
color([0.36, 0.33, 0.30]) rotate([-8, 0, 0]) yoke();
|
||||||
|
color([0.33, 0.31, 0.29]) cradle();
|
||||||
|
color([0.12, 0.18, 0.14]) translate([-cam[0]/2, -cam_standoff + 1.5 - cam_pcb, cam_lens_top - cam[1]]) cube([cam[0], cam_pcb, cam[1]]);
|
||||||
|
color([0.05, 0.05, 0.05]) translate([-4, -cam_standoff + 1.5 - cam_pcb - 5, -4]) cube([8, 5, 8]);
|
||||||
|
color([0.15, 0.3, 0.35]) translate([0, -cam_standoff + 1.5 - cam_pcb - 5.2, 0]) rotate([90, 0, 0]) cylinder(d = 5, h = 0.4);
|
||||||
|
color([0.15, 0.17, 0.22]) rotate([-8, 0, 0]) translate([x_tip, 0, 0]) rotate([0, 90, 0]) rotate([0, 0, 90]) dummy_servo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ posed parts for external rendering
|
||||||
|
pose_pan = -12; // body turned toward a viewer at front-left
|
||||||
|
pose_tilt = 8; // camera dipped slightly
|
||||||
|
module pose(n) {
|
||||||
|
if (n == "base") base();
|
||||||
|
if (n == "servo_pan") translate([0, 0, base_h + gap_turn + horn_t - horn_seat]) dummy_servo();
|
||||||
|
rotate([0, 0, pose_pan]) {
|
||||||
|
if (n == "plate") translate([0, 0, z_body0]) plate();
|
||||||
|
translate([-axis[0], -axis[1], z_body0]) {
|
||||||
|
if (n == "body") body();
|
||||||
|
translate([axis[0] + cam_x_offset, y_tilt, H + tilt_axis_h]) {
|
||||||
|
if (n == "yoke") yoke();
|
||||||
|
if (n == "servo_tilt") translate([x_tip, 0, 0]) rotate([0, 90, 0]) rotate([0, 0, 90]) dummy_servo();
|
||||||
|
rotate([pose_tilt, 0, 0]) {
|
||||||
|
if (n == "cradle") cradle();
|
||||||
|
if (n == "camera") dummy_camera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// window and LED positions in the same posed frame, for compositing
|
||||||
|
function posed(p) = let(a = pose_pan, q = [p[0] - axis[0], p[1] - axis[1], p[2] + z_body0])
|
||||||
|
[q[0]*cos(a) - q[1]*sin(a), q[0]*sin(a) + q[1]*cos(a), q[2]];
|
||||||
|
if (PART == "pose_info") {
|
||||||
|
cx = BX(disp_py_c); cz = BZ(disp_px_c);
|
||||||
|
for (c = [[-1, 1], [1, 1], [1, -1], [-1, -1]])
|
||||||
|
echo(str("WIN ", posed([cx + c[0]*win_w/2, wall * 0.6, cz + c[1]*win_h/2])));
|
||||||
|
for (l = leds) echo(str("LED ", posed([BX(l[1]), 0.6, BZ(l[0])])));
|
||||||
|
echo(str("NORMAL ", posed([0, -1, 0]) - posed([0, 0, 0])));
|
||||||
|
}
|
||||||
|
if (len(PART) > 5 && PART[0] == "p" && PART[1] == "o" && PART[2] == "s" && PART[3] == "e" && PART[4] == "_" && PART != "pose_info")
|
||||||
|
pose(chr([for (i = [5 : len(PART) - 1]) ord(PART[i])]));
|
||||||
|
|
||||||
|
// ============================================================ test pieces
|
||||||
|
module test_front() intersection() { // front wall over the HAT, with the locating cups
|
||||||
|
body();
|
||||||
|
translate([BX(-3), -1, BZ(68)]) cube([pi_wid + 6, y_hat_f + 1, 72]);
|
||||||
|
}
|
||||||
|
module test_bearing() {
|
||||||
|
intersection() { base(); translate([0, 0, z_brg0 - 2]) cylinder(r = 50, h = 20); }
|
||||||
|
translate([80, 0, 0]) rotate([180, 0, 0]) intersection() { // hub ring, plate face down as printed
|
||||||
|
plate(); translate([0, 0, -30]) cylinder(d = brg_in_shoulder + 2, h = 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module test_servo() intersection() { base(); cylinder(r = 26, h = z_srv_bottom + srv_tab_z + 1); }
|
||||||
|
module test_yoke() intersection() {
|
||||||
|
yoke();
|
||||||
|
translate([x_tabtop - 1, -40, -tilt_axis_h - 1]) cube([mp_t + 2, 80, 80]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ output
|
||||||
|
if (PART == "assembly") placed(0);
|
||||||
|
else if (PART == "exploded") placed(28);
|
||||||
|
else if (PART == "beauty") beauty();
|
||||||
|
else if (PART == "body") rotate([90, 0, 0]) body(); // front face on the bed
|
||||||
|
else if (PART == "back") translate([0, 0, D]) rotate([-90, 0, 0]) back(); // outside face on the bed
|
||||||
|
else if (PART == "plate") rotate([180, 0, 0]) plate(); // top face on the bed
|
||||||
|
else if (PART == "base") base();
|
||||||
|
else if (PART == "yoke") translate([0, 0, tilt_axis_h]) yoke();
|
||||||
|
else if (PART == "cradle") translate([0, 0, cb_l + fl_t]) rotate([0, -90, 0]) cradle(); // horn flange on the bed
|
||||||
|
else if (PART == "test_front") rotate([90, 0, 0]) test_front();
|
||||||
|
else if (PART == "test_bearing") test_bearing();
|
||||||
|
else if (PART == "test_servo") test_servo();
|
||||||
|
else if (PART == "test_yoke") translate([0, 0, tilt_axis_h]) test_yoke();
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Poster
|
||||||
|
|
||||||
|
`hermes_poster.png` (1600 × 2400) is rendered from the real enclosure model: hidden-line wireframe
|
||||||
|
of the feature edges and silhouettes, 6 mm contour slices on the shells, the flat P7 face lit on
|
||||||
|
the screen, a mirrored pass for the wet floor, then haze, bloom and CRT scanlines.
|
||||||
|
|
||||||
|
To regenerate after changing the model:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd enclosure
|
||||||
|
for n in base servo_pan plate body yoke servo_tilt cradle camera; do
|
||||||
|
openscad -o poster/pose/$n.stl -D "PART=\"pose_$n\"" -D '$fn=48' hermes_robot.scad
|
||||||
|
done
|
||||||
|
openscad -o /dev/null -D 'PART="pose_info"' hermes_robot.scad # prints WIN / LED coordinates
|
||||||
|
python3 poster/render_poster.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If the window or LED positions change, copy the printed `WIN` and `LED` values into
|
||||||
|
`render_poster.py`. `face.png` is a frame captured from `docs/face-prototype.html` (flat shape,
|
||||||
|
P7, speaking). Requires `numpy`, `trimesh` and `Pillow`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.6 MiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
|||||||
|
solid OpenSCAD_Model
|
||||||
|
facet normal 0.97815 -0.207899 3.31478e-06
|
||||||
|
outer loop
|
||||||
|
vertex 9.46323 -15.6007 219.72
|
||||||
|
vertex 10.3636 -11.3649 196.093
|
||||||
|
vertex 9.66912 -14.632 219.859
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.978144 -0.207929 -2.20173e-06
|
||||||
|
outer loop
|
||||||
|
vertex 10.3636 -11.3649 196.093
|
||||||
|
vertex 9.46323 -15.6007 219.72
|
||||||
|
vertex 10.1577 -12.3335 195.954
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.0288981 -0.135956 0.990293
|
||||||
|
outer loop
|
||||||
|
vertex -14.7846 -9.43426 219.859
|
||||||
|
vertex 9.46323 -15.6007 219.72
|
||||||
|
vertex 9.66912 -14.632 219.859
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.0289 -0.135964 0.990292
|
||||||
|
outer loop
|
||||||
|
vertex 9.46323 -15.6007 219.72
|
||||||
|
vertex -14.7846 -9.43426 219.859
|
||||||
|
vertex -14.9905 -10.4029 219.72
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.0289012 0.135969 -0.990291
|
||||||
|
outer loop
|
||||||
|
vertex 10.1577 -12.3335 195.954
|
||||||
|
vertex -14.0901 -6.16709 196.093
|
||||||
|
vertex 10.3636 -11.3649 196.093
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.0289002 0.135965 -0.990292
|
||||||
|
outer loop
|
||||||
|
vertex -14.0901 -6.16709 196.093
|
||||||
|
vertex 10.1577 -12.3335 195.954
|
||||||
|
vertex -14.296 -7.13572 195.954
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.978145 0.207923 -4.88768e-08
|
||||||
|
outer loop
|
||||||
|
vertex -14.9905 -10.4029 219.72
|
||||||
|
vertex -14.0901 -6.16709 196.093
|
||||||
|
vertex -14.296 -7.13572 195.954
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.978146 0.207921 -4.24054e-07
|
||||||
|
outer loop
|
||||||
|
vertex -14.0901 -6.16709 196.093
|
||||||
|
vertex -14.9905 -10.4029 219.72
|
||||||
|
vertex -14.7846 -9.43426 219.859
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.205889 0.968628 0.139173
|
||||||
|
outer loop
|
||||||
|
vertex -14.0901 -6.16709 196.093
|
||||||
|
vertex 9.66912 -14.632 219.859
|
||||||
|
vertex 10.3636 -11.3649 196.093
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.205886 0.968628 0.139176
|
||||||
|
outer loop
|
||||||
|
vertex 9.66912 -14.632 219.859
|
||||||
|
vertex -14.0901 -6.16709 196.093
|
||||||
|
vertex -14.7846 -9.43426 219.859
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.205888 -0.968629 -0.139166
|
||||||
|
outer loop
|
||||||
|
vertex -14.9905 -10.4029 219.72
|
||||||
|
vertex -6.51822 -11.4269 214.313
|
||||||
|
vertex 9.46323 -15.6007 219.72
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.205898 -0.968625 -0.139181
|
||||||
|
outer loop
|
||||||
|
vertex -14.9905 -10.4029 219.72
|
||||||
|
vertex -6.28673 -10.3378 206.391
|
||||||
|
vertex -6.51822 -11.4269 214.313
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.205889 -0.968627 -0.139178
|
||||||
|
outer loop
|
||||||
|
vertex -6.28673 -10.3378 206.391
|
||||||
|
vertex -14.296 -7.13572 195.954
|
||||||
|
vertex 1.53845 -12.0011 206.391
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.205891 -0.968627 -0.139177
|
||||||
|
outer loop
|
||||||
|
vertex -14.296 -7.13572 195.954
|
||||||
|
vertex -6.28673 -10.3378 206.391
|
||||||
|
vertex -14.9905 -10.4029 219.72
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.205876 -0.968627 -0.1392
|
||||||
|
outer loop
|
||||||
|
vertex 1.30697 -13.0901 214.313
|
||||||
|
vertex 9.46323 -15.6007 219.72
|
||||||
|
vertex -6.51822 -11.4269 214.313
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.205897 -0.968627 -0.139169
|
||||||
|
outer loop
|
||||||
|
vertex 1.53845 -12.0011 206.391
|
||||||
|
vertex 9.46323 -15.6007 219.72
|
||||||
|
vertex 1.30697 -13.0901 214.313
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.205884 -0.968629 -0.139177
|
||||||
|
outer loop
|
||||||
|
vertex 1.53845 -12.0011 206.391
|
||||||
|
vertex 10.1577 -12.3335 195.954
|
||||||
|
vertex 9.46323 -15.6007 219.72
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.205888 -0.968627 -0.13918
|
||||||
|
outer loop
|
||||||
|
vertex 10.1577 -12.3335 195.954
|
||||||
|
vertex 1.53845 -12.0011 206.391
|
||||||
|
vertex -14.296 -7.13572 195.954
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.978148 -0.20791 9.31053e-07
|
||||||
|
outer loop
|
||||||
|
vertex 0.277524 -17.9333 213.617
|
||||||
|
vertex 1.53845 -12.0011 206.391
|
||||||
|
vertex 1.30697 -13.0901 214.313
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.978147 -0.207913 -1.33146e-06
|
||||||
|
outer loop
|
||||||
|
vertex 1.53845 -12.0011 206.391
|
||||||
|
vertex 0.277524 -17.9333 213.617
|
||||||
|
vertex 0.50901 -16.8442 205.695
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.0289393 -0.136156 0.990265
|
||||||
|
outer loop
|
||||||
|
vertex -6.51822 -11.4269 214.313
|
||||||
|
vertex 0.277524 -17.9333 213.617
|
||||||
|
vertex 1.30697 -13.0901 214.313
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.0289415 -0.136159 0.990264
|
||||||
|
outer loop
|
||||||
|
vertex 0.277524 -17.9333 213.617
|
||||||
|
vertex -6.51822 -11.4269 214.313
|
||||||
|
vertex -7.54766 -16.27 213.617
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.0289415 0.136159 -0.990264
|
||||||
|
outer loop
|
||||||
|
vertex 0.50901 -16.8442 205.695
|
||||||
|
vertex -6.28673 -10.3378 206.391
|
||||||
|
vertex 1.53845 -12.0011 206.391
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.0289415 0.136159 -0.990264
|
||||||
|
outer loop
|
||||||
|
vertex -6.28673 -10.3378 206.391
|
||||||
|
vertex 0.50901 -16.8442 205.695
|
||||||
|
vertex -7.31617 -15.1809 205.695
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.978147 0.207913 8.47143e-07
|
||||||
|
outer loop
|
||||||
|
vertex -7.54766 -16.27 213.617
|
||||||
|
vertex -6.28673 -10.3378 206.391
|
||||||
|
vertex -7.31617 -15.1809 205.695
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.978147 0.207913 8.47143e-07
|
||||||
|
outer loop
|
||||||
|
vertex -6.28673 -10.3378 206.391
|
||||||
|
vertex -7.54766 -16.27 213.617
|
||||||
|
vertex -6.51822 -11.4269 214.313
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.205889 -0.968627 -0.139181
|
||||||
|
outer loop
|
||||||
|
vertex -7.31617 -15.1809 205.695
|
||||||
|
vertex 0.277524 -17.9333 213.617
|
||||||
|
vertex -7.54766 -16.27 213.617
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.205889 -0.968627 -0.139181
|
||||||
|
outer loop
|
||||||
|
vertex 0.277524 -17.9333 213.617
|
||||||
|
vertex -7.31617 -15.1809 205.695
|
||||||
|
vertex 0.50901 -16.8442 205.695
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
endsolid OpenSCAD_Model
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Advertisement poster for the Hermes robot: a hidden-line wireframe render of the real enclosure
|
||||||
|
model, the scope face lit on its screen, on a wet floor in a hazy, Blade Runner-ish palette,
|
||||||
|
finished with CRT scanlines.
|
||||||
|
|
||||||
|
Inputs (made by the commands in poster/README.md):
|
||||||
|
pose/*.stl each part exported in its assembled pose (PART="pose_<name>")
|
||||||
|
face.png a frame of the face from docs/face-prototype.html (flat, P3 amber)
|
||||||
|
Output: hermes_poster.png
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import trimesh
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageChops
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
POSE = Path(sys.argv[1]) if len(sys.argv) > 1 else HERE / "pose"
|
||||||
|
FACE = Path(sys.argv[2]) if len(sys.argv) > 2 else HERE / "face.png"
|
||||||
|
OUT = Path(sys.argv[3]) if len(sys.argv) > 3 else HERE / "hermes_poster.png"
|
||||||
|
|
||||||
|
PW, PH = 1600, 2400
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ palette (linear-ish 0..1)
|
||||||
|
INK = np.array([0.020, 0.028, 0.036]) # night
|
||||||
|
HAZE = np.array([0.10, 0.20, 0.22]) # wet teal smoke
|
||||||
|
AMBER = np.array([1.00, 0.66, 0.24]) # phosphor / sodium light
|
||||||
|
WARM = np.array([0.95, 0.86, 0.70]) # edge highlight
|
||||||
|
TEAL = np.array([0.30, 0.66, 0.70]) # contours, grid
|
||||||
|
SCREEN = np.array([0.66, 0.82, 1.00]) # P7 phosphor: blue-white
|
||||||
|
FONT_B = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||||
|
FONT_L = "/usr/share/fonts/truetype/dejavu/DejaVuSans-ExtraLight.ttf"
|
||||||
|
FONT_C = "/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed.ttf"
|
||||||
|
FONT_M = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ camera
|
||||||
|
target = np.array([0.0, 0.0, 112.0])
|
||||||
|
direction = np.array([-0.40, -0.91, -0.10])
|
||||||
|
direction /= np.linalg.norm(direction)
|
||||||
|
eye = target + direction * 950.0
|
||||||
|
fov = math.radians(24.0)
|
||||||
|
F = (PH / 2) / math.tan(fov / 2)
|
||||||
|
CX, CY = PW * 0.5, PH * 0.565
|
||||||
|
fwd = target - eye; fwd /= np.linalg.norm(fwd)
|
||||||
|
right = np.cross(fwd, [0, 0, 1.0]); right /= np.linalg.norm(right)
|
||||||
|
up = np.cross(right, fwd)
|
||||||
|
|
||||||
|
|
||||||
|
def project(p):
|
||||||
|
v = p - eye
|
||||||
|
zc = v @ fwd
|
||||||
|
return np.stack([CX + F * (v @ right) / zc, CY - F * (v @ up) / zc, zc], axis=-1)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ geometry
|
||||||
|
parts = {}
|
||||||
|
for f in sorted(POSE.glob("*.stl")):
|
||||||
|
m = trimesh.load(f)
|
||||||
|
parts[f.stem] = m
|
||||||
|
print("parts:", {k: len(v.faces) for k, v in parts.items()})
|
||||||
|
|
||||||
|
|
||||||
|
def render_pass(mirror=False):
|
||||||
|
"""Depth buffer, shading and line buffers for one pass (mirror=True: reflection in the floor)."""
|
||||||
|
zbuf = Image.new("F", (PW, PH), 1e9)
|
||||||
|
shade = Image.new("RGB", (PW, PH), (0, 0, 0))
|
||||||
|
dz, ds = ImageDraw.Draw(zbuf), ImageDraw.Draw(shade)
|
||||||
|
key = np.array([-0.55, -0.35, 0.75]); key /= np.linalg.norm(key) # amber key, front-left high
|
||||||
|
rim = np.array([0.75, 0.55, 0.35]); rim /= np.linalg.norm(rim) # teal rim from behind right
|
||||||
|
tris_all = []
|
||||||
|
for name, m in parts.items():
|
||||||
|
mm = m.copy()
|
||||||
|
if mirror:
|
||||||
|
mm.vertices[:, 2] *= -1
|
||||||
|
mm.invert()
|
||||||
|
sub = mm.subdivide_to_size(max_edge=1.1 if name == "body" else 2.0, max_iter=10)
|
||||||
|
v = sub.vertices[sub.faces] # (n, 3, 3)
|
||||||
|
n = sub.face_normals
|
||||||
|
p = project(v.reshape(-1, 3)).reshape(-1, 3, 3)
|
||||||
|
view = (eye - v.mean(axis=1)); view /= np.linalg.norm(view, axis=1, keepdims=True)
|
||||||
|
front = (n * view).sum(1) > 0
|
||||||
|
tris_all.append((p[front], n[front], view[front]))
|
||||||
|
p = np.concatenate([t[0] for t in tris_all]); n = np.concatenate([t[1] for t in tris_all])
|
||||||
|
view = np.concatenate([t[2] for t in tris_all])
|
||||||
|
order = np.argsort(-p[:, :, 2].mean(1)) # far to near
|
||||||
|
lk = np.clip(n @ key, 0, 1); lr = np.clip(n @ rim, 0, 1)
|
||||||
|
fres = (1 - np.abs((n * view).sum(1))) ** 2
|
||||||
|
for i in order:
|
||||||
|
pts = [tuple(xy) for xy in p[i, :, :2]]
|
||||||
|
dz.polygon(pts, fill=float(p[i, :, 2].mean()))
|
||||||
|
c = 0.035 + 0.10 * lk[i] * AMBER + 0.08 * lr[i] * TEAL + 0.10 * fres[i] * TEAL
|
||||||
|
ds.polygon(pts, fill=tuple(int(min(255, 255 * x)) for x in c))
|
||||||
|
z = np.asarray(zbuf).copy()
|
||||||
|
# widen with a max filter so lines lying on a surface pass the depth test
|
||||||
|
zmax = np.asarray(zbuf.filter(ImageFilter.MaxFilter(3)))
|
||||||
|
mask = (z < 1e8).astype(np.float32)
|
||||||
|
|
||||||
|
# lines: feature edges, silhouettes, horizontal contour slices
|
||||||
|
edge_buf = np.zeros((PH, PW), np.float32)
|
||||||
|
cont_buf = np.zeros((PH, PW), np.float32)
|
||||||
|
|
||||||
|
def splat(buf, a3, b3, weight, spacing=0.6):
|
||||||
|
pa, pb = project(a3), project(b3)
|
||||||
|
L = np.hypot(pb[:, 0] - pa[:, 0], pb[:, 1] - pa[:, 1])
|
||||||
|
k = np.maximum(1, np.ceil(L / spacing)).astype(int)
|
||||||
|
idx = np.repeat(np.arange(len(k)), k)
|
||||||
|
t = (np.arange(k.sum()) - np.repeat(np.cumsum(k) - k, k)) / np.repeat(k, k)
|
||||||
|
P = a3[idx] + (b3[idx] - a3[idx]) * t[:, None]
|
||||||
|
q = project(P)
|
||||||
|
x, y, zc = q[:, 0], q[:, 1], q[:, 2]
|
||||||
|
ok = (x >= 0) & (x < PW - 1) & (y >= 0) & (y < PH - 1)
|
||||||
|
x, y, zc = x[ok], y[ok], zc[ok]
|
||||||
|
xi, yi = x.astype(int), y.astype(int)
|
||||||
|
vis = zc <= zmax[yi, xi] + 0.8
|
||||||
|
x, y, xi, yi = x[vis], y[vis], xi[vis], yi[vis]
|
||||||
|
fx, fy = x - xi, y - yi
|
||||||
|
w = weight
|
||||||
|
np.add.at(buf, (yi, xi), w * (1 - fx) * (1 - fy))
|
||||||
|
np.add.at(buf, (yi, xi + 1), w * fx * (1 - fy))
|
||||||
|
np.add.at(buf, (yi + 1, xi), w * (1 - fx) * fy)
|
||||||
|
np.add.at(buf, (yi + 1, xi + 1), w * fx * fy)
|
||||||
|
|
||||||
|
for name, m in parts.items():
|
||||||
|
mm = m.copy()
|
||||||
|
if mirror:
|
||||||
|
mm.vertices[:, 2] *= -1
|
||||||
|
mm.invert()
|
||||||
|
adj = mm.face_adjacency
|
||||||
|
ang = mm.face_adjacency_angles
|
||||||
|
e = mm.face_adjacency_edges
|
||||||
|
fn = mm.face_normals
|
||||||
|
mid = mm.vertices[e].mean(1)
|
||||||
|
vv = eye - mid
|
||||||
|
s1 = (fn[adj[:, 0]] * vv).sum(1) > 0
|
||||||
|
s2 = (fn[adj[:, 1]] * vv).sum(1) > 0
|
||||||
|
crease = ang > math.radians(28)
|
||||||
|
silhouette = s1 != s2
|
||||||
|
sel = crease | silhouette
|
||||||
|
V = mm.vertices
|
||||||
|
splat(edge_buf, V[e[sel, 0]], V[e[sel, 1]], 0.8)
|
||||||
|
# contour slices every 6 mm on the big shells: the "scan" look
|
||||||
|
if name in ("body", "base", "plate", "yoke"):
|
||||||
|
zlo, zhi = mm.bounds[0][2], mm.bounds[1][2]
|
||||||
|
for h in np.arange(math.ceil(zlo / 6) * 6, zhi, 6.0):
|
||||||
|
try:
|
||||||
|
seg = trimesh.intersections.mesh_plane(mm, [0, 0, 1], [0, 0, h])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if len(seg):
|
||||||
|
splat(cont_buf, seg[:, 0], seg[:, 1], 0.32)
|
||||||
|
return z, mask, np.asarray(shade).astype(np.float32) / 255, edge_buf, cont_buf
|
||||||
|
|
||||||
|
|
||||||
|
print("main pass…")
|
||||||
|
z, mask, shade, edges, conts = render_pass(False)
|
||||||
|
print("reflection pass…")
|
||||||
|
zr, maskr, shader, edgesr, contsr = render_pass(True)
|
||||||
|
|
||||||
|
|
||||||
|
def blur(a, r):
|
||||||
|
im = Image.fromarray(np.clip(a * 255, 0, 255).astype(np.uint8))
|
||||||
|
return np.asarray(im.filter(ImageFilter.GaussianBlur(r))).astype(np.float32) / 255
|
||||||
|
|
||||||
|
|
||||||
|
def blur3(rgb, r):
|
||||||
|
return np.stack([blur(rgb[..., c], r) for c in range(3)], -1)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ background
|
||||||
|
yy, xx = np.mgrid[0:PH, 0:PW].astype(np.float32)
|
||||||
|
horizon = project(np.array([[0, 2000.0, 0]]))[0, 1] # far floor
|
||||||
|
floor_y = project(np.array([[0, 0, 0.0]]))[0, 1]
|
||||||
|
bg = np.zeros((PH, PW, 3), np.float32) + INK
|
||||||
|
g = np.clip(1 - np.abs(yy - horizon) / 900, 0, 1) ** 1.6 # band of haze at the horizon
|
||||||
|
bg += g[..., None] * HAZE * 0.55
|
||||||
|
# sodium glow low behind the robot, smoke plume, a cold light from upper right
|
||||||
|
glow = np.exp(-(((xx - PW * 0.42) / 520) ** 2 + ((yy - horizon - 40) / 260) ** 2))
|
||||||
|
bg += glow[..., None] * AMBER * 0.16
|
||||||
|
plume = np.exp(-(((xx - PW * 0.55) / 380) ** 2 + ((yy - PH * 0.45) / 700) ** 2))
|
||||||
|
bg += plume[..., None] * HAZE * 0.22
|
||||||
|
# light shafts
|
||||||
|
for k, (x0, wdt, a) in enumerate([(1250, 70, 0.05), (1380, 40, 0.035), (1100, 120, 0.03)]):
|
||||||
|
d = (xx - (x0 - (yy - 0) * 0.42)) / wdt
|
||||||
|
shaft = np.exp(-d ** 2) * np.clip(1 - yy / (PH * 0.75), 0, 1)
|
||||||
|
bg += shaft[..., None] * HAZE * a * 4
|
||||||
|
# rain
|
||||||
|
rain = Image.new("L", (PW, PH), 0); dr = ImageDraw.Draw(rain)
|
||||||
|
for _ in range(900):
|
||||||
|
x = rng.uniform(-200, PW); y = rng.uniform(-100, PH * 0.78); L = rng.uniform(30, 120)
|
||||||
|
dr.line([(x, y), (x + L * 0.18, y + L)], fill=int(rng.uniform(25, 80)), width=1)
|
||||||
|
rain = np.asarray(rain.filter(ImageFilter.GaussianBlur(0.7))).astype(np.float32) / 255
|
||||||
|
bg += rain[..., None] * np.array([0.55, 0.65, 0.70]) * 0.45
|
||||||
|
|
||||||
|
# perspective floor grid
|
||||||
|
grid = Image.new("L", (PW, PH), 0); dg = ImageDraw.Draw(grid)
|
||||||
|
for gx in np.arange(-1200, 1201, 50.0):
|
||||||
|
a, b = project(np.array([[gx, -700, 0], [gx, 2400, 0]]))
|
||||||
|
if a[2] > 0 and b[2] > 0:
|
||||||
|
dg.line([tuple(a[:2]), tuple(b[:2])], fill=60, width=1)
|
||||||
|
for gy in np.arange(-700, 2401, 50.0):
|
||||||
|
a, b = project(np.array([[-1500, gy, 0], [1500, gy, 0]]))
|
||||||
|
fade = int(70 * math.exp(-max(0, gy) / 900))
|
||||||
|
dg.line([tuple(a[:2]), tuple(b[:2])], fill=fade, width=1)
|
||||||
|
gridf = np.asarray(grid).astype(np.float32) / 255
|
||||||
|
gridf *= (yy > horizon + 10)
|
||||||
|
# fade the grid toward the horizon
|
||||||
|
gridf *= np.clip((yy - horizon) / 500, 0, 1)
|
||||||
|
bg += gridf[..., None] * TEAL * 0.55
|
||||||
|
# wet floor sheen
|
||||||
|
floor = (yy > horizon).astype(np.float32) * np.clip((yy - horizon) / 300, 0, 1)
|
||||||
|
bg *= 1 - 0.25 * floor[..., None]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ robot layers
|
||||||
|
def robot_rgb(shade, edges, conts, mask):
|
||||||
|
e = 1 - np.exp(-edges * 1.6)
|
||||||
|
c = 1 - np.exp(-conts * 1.4)
|
||||||
|
rgb = shade * 0.9
|
||||||
|
rgb = rgb * (1 - 0.35 * c[..., None]) + c[..., None] * TEAL * 0.55
|
||||||
|
rgb = rgb + e[..., None] * WARM * 0.95
|
||||||
|
return rgb, e
|
||||||
|
|
||||||
|
|
||||||
|
robot, e_main = robot_rgb(shade, edges, conts, mask)
|
||||||
|
refl, _ = robot_rgb(shader, edgesr, contsr, maskr)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ the face on the screen
|
||||||
|
def persp_coeffs(src, dst):
|
||||||
|
"""Coefficients for Image.transform(PERSPECTIVE): maps output (dst) pixels back to src."""
|
||||||
|
A, B = [], []
|
||||||
|
for (x, y), (u, v) in zip(dst, src):
|
||||||
|
A.append([x, y, 1, 0, 0, 0, -u * x, -u * y]); B.append(u)
|
||||||
|
A.append([0, 0, 0, x, y, 1, -v * x, -v * y]); B.append(v)
|
||||||
|
return np.linalg.solve(np.array(A, float), np.array(B, float))
|
||||||
|
|
||||||
|
|
||||||
|
WIN = np.array([[-17.8464, -15.1813, 161.8], [10.1287, -21.1276, 161.8],
|
||||||
|
[10.1287, -21.1276, 133.2], [-17.8464, -15.1813, 133.2]])
|
||||||
|
LEDS = np.array([[9.66058, -21.8868, 126.7], [9.66058, -21.8868, 117.9], [9.66058, -21.8868, 109.5]])
|
||||||
|
|
||||||
|
|
||||||
|
def face_layer(win3):
|
||||||
|
face = Image.open(FACE).convert("RGB")
|
||||||
|
fw, fh = face.size
|
||||||
|
q = project(win3)[:, :2]
|
||||||
|
coeffs = persp_coeffs([(0, 0), (fw, 0), (fw, fh), (0, fh)], [tuple(p) for p in q])
|
||||||
|
warped = face.transform((PW, PH), Image.PERSPECTIVE, tuple(coeffs), Image.BILINEAR)
|
||||||
|
poly = Image.new("L", (PW, PH), 0); ImageDraw.Draw(poly).polygon([tuple(p) for p in q], fill=255)
|
||||||
|
a = np.asarray(warped).astype(np.float32) / 255 * (np.asarray(poly)[..., None] / 255)
|
||||||
|
glass = (np.asarray(poly).astype(np.float32) / 255)[..., None] * np.array([0.05, 0.035, 0.02])
|
||||||
|
return a + glass, np.asarray(poly).astype(np.float32) / 255
|
||||||
|
|
||||||
|
|
||||||
|
face, facepoly = face_layer(WIN)
|
||||||
|
mirror = lambda p: p * np.array([1, 1, -1])
|
||||||
|
facer, facepolyr = face_layer(mirror(WIN))
|
||||||
|
|
||||||
|
# LEDs glowing through the PETG skin: amber "speaking", the others dark
|
||||||
|
led = np.zeros((PH, PW), np.float32)
|
||||||
|
for i, p in enumerate(project(LEDS)):
|
||||||
|
if i == 0:
|
||||||
|
led += np.exp(-(((xx - p[0]) / 5) ** 2 + ((yy - p[1]) / 5) ** 2))
|
||||||
|
ledrgb = led[..., None] * AMBER * 0.9
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ composite
|
||||||
|
img = bg.copy()
|
||||||
|
# reflection: dimmed, blurred, fading with distance below the base
|
||||||
|
refl_all = refl + facer * 1.2
|
||||||
|
fadeR = np.clip(1 - (yy - floor_y) / 520, 0, 1) ** 1.5 * (yy > floor_y - 4)
|
||||||
|
refl_all = blur3(refl_all, 2.2) * (0.42 * fadeR)[..., None]
|
||||||
|
mr = blur(maskr, 2.0) * fadeR
|
||||||
|
img = img * (1 - 0.55 * mr[..., None]) + refl_all
|
||||||
|
# robot
|
||||||
|
img = img * (1 - mask[..., None]) + robot * mask[..., None]
|
||||||
|
img = img + face * 1.15 + ledrgb
|
||||||
|
# bloom from the screen and the brightest edges
|
||||||
|
bright = face * 1.2 + e_main[..., None] * WARM * 0.25 + ledrgb
|
||||||
|
img += blur3(bright, 9) * 0.9 + blur3(bright, 40) * 0.55
|
||||||
|
# amber spill from the screen onto the body front
|
||||||
|
scr = project(WIN).mean(0)
|
||||||
|
spill = np.exp(-(((xx - scr[0]) / 170) ** 2 + ((yy - scr[1]) / 210) ** 2)) * mask
|
||||||
|
img += spill[..., None] * SCREEN * 0.07
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ typography and callouts
|
||||||
|
ui = Image.new("RGBA", (PW, PH), (0, 0, 0, 0))
|
||||||
|
du = ImageDraw.Draw(ui)
|
||||||
|
|
||||||
|
|
||||||
|
def tracked(draw, xy, text, font, fill, tracking, anchor_center=False):
|
||||||
|
widths = [draw.textlength(ch, font=font) for ch in text]
|
||||||
|
total = sum(widths) + tracking * (len(text) - 1)
|
||||||
|
x, y = xy
|
||||||
|
if anchor_center:
|
||||||
|
x -= total / 2
|
||||||
|
for ch, w in zip(text, widths):
|
||||||
|
draw.text((x, y), ch, font=font, fill=fill)
|
||||||
|
x += w + tracking
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
warm_t = (242, 224, 196, 255)
|
||||||
|
amber_t = (255, 176, 82, 255)
|
||||||
|
teal_t = (110, 182, 188, 255)
|
||||||
|
dim_t = (150, 165, 168, 210)
|
||||||
|
|
||||||
|
f_kick = ImageFont.truetype(FONT_M, 26)
|
||||||
|
f_head = ImageFont.truetype(FONT_B, 210)
|
||||||
|
f_tag = ImageFont.truetype(FONT_L, 58)
|
||||||
|
f_call_h = ImageFont.truetype(FONT_C, 30)
|
||||||
|
f_call = ImageFont.truetype(FONT_M, 22)
|
||||||
|
f_spec = ImageFont.truetype(FONT_M, 24)
|
||||||
|
f_fine = ImageFont.truetype(FONT_M, 19)
|
||||||
|
|
||||||
|
M = 96
|
||||||
|
tracked(du, (M, 92), "PERSONAL AGENT UNIT // MODEL H-1", f_kick, teal_t, 6)
|
||||||
|
du.line([(M, 138), (PW - M, 138)], fill=(110, 182, 188, 90), width=1)
|
||||||
|
head_layer = Image.new("RGBA", (PW, PH), (0, 0, 0, 0))
|
||||||
|
dh = ImageDraw.Draw(head_layer)
|
||||||
|
tracked(dh, (PW / 2, 150), "HERMES", f_head, warm_t, 46, anchor_center=True)
|
||||||
|
glow_h = head_layer.filter(ImageFilter.GaussianBlur(18))
|
||||||
|
tracked(du, (PW / 2, 400), "A face for the mind on your machine.", f_tag, (232, 214, 186, 235), 3, anchor_center=True)
|
||||||
|
|
||||||
|
# callouts: anchor on the model, label off to the side
|
||||||
|
def callout(p3, label, sub, side, y_label):
|
||||||
|
p = project(np.array([p3]))[0]
|
||||||
|
x_lab = M + 10 if side < 0 else PW - M - 10
|
||||||
|
wl = sum(du.textlength(c, font=f_call_h) for c in label) + 4 * (len(label) - 1)
|
||||||
|
ws = sum(du.textlength(c, font=f_call) for c in sub) + 2 * (len(sub) - 1)
|
||||||
|
span = max(wl, ws) + 18
|
||||||
|
elbow = (x_lab + (span if side < 0 else -span), y_label)
|
||||||
|
du.ellipse([p[0] - 7, p[1] - 7, p[0] + 7, p[1] + 7], outline=teal_t, width=2)
|
||||||
|
du.line([(p[0], p[1]), elbow, (x_lab, y_label)], fill=(110, 182, 188, 200), width=2)
|
||||||
|
if side < 0:
|
||||||
|
tracked(du, (x_lab, y_label - 44), label, f_call_h, warm_t, 4)
|
||||||
|
tracked(du, (x_lab, y_label + 10), sub, f_call, dim_t, 2)
|
||||||
|
else:
|
||||||
|
w = sum(du.textlength(c, font=f_call_h) for c in label) + 4 * (len(label) - 1)
|
||||||
|
tracked(du, (x_lab - w, y_label - 44), label, f_call_h, warm_t, 4)
|
||||||
|
w2 = sum(du.textlength(c, font=f_call) for c in sub) + 2 * (len(sub) - 1)
|
||||||
|
tracked(du, (x_lab - w2, y_label + 10), sub, f_call, dim_t, 2)
|
||||||
|
|
||||||
|
|
||||||
|
a = math.radians(-12)
|
||||||
|
def posed(X, Y, Z): # body coordinates -> posed frame (matches pose() in the .scad)
|
||||||
|
qx, qy, qz = X - 43, Y - 20, Z + 38.6
|
||||||
|
return [qx * math.cos(a) - qy * math.sin(a), qx * math.sin(a) + qy * math.cos(a), qz]
|
||||||
|
|
||||||
|
lens = posed(43, 3.0, 146.1 + 26 - 1.0)
|
||||||
|
grille = posed(48, 0, 25.5)
|
||||||
|
win_c = WIN.mean(0)
|
||||||
|
plate_edge = [38 * math.sin(math.radians(-150)), 38 * math.cos(math.radians(-150)), 36.6]
|
||||||
|
callout(lens, "EYE", "5 MP OPTICAL · TILT ±30°", 1, 640)
|
||||||
|
callout(win_c, "FACE", "240 × 240 PHOSPHOR", 1, 1020)
|
||||||
|
callout(grille, "VOICE", "8 Ω SPEAKER · STEREO EARS", -1, 1560)
|
||||||
|
callout(plate_edge, "NECK", "PAN ±80° ON A BALL RACE", 1, 1880)
|
||||||
|
|
||||||
|
# footer
|
||||||
|
du.line([(M, PH - 250), (PW - M, PH - 250)], fill=(110, 182, 188, 90), width=1)
|
||||||
|
tracked(du, (PW / 2, PH - 222), "LISTENS. LOOKS. ANSWERS.", ImageFont.truetype(FONT_B, 40), amber_t, 14, anchor_center=True)
|
||||||
|
tracked(du, (PW / 2, PH - 150), "THE THINKING STAYS ON YOUR OWN COMPUTER.", f_spec, dim_t, 5, anchor_center=True)
|
||||||
|
tracked(du, (M, PH - 78), "RUNS WITH HERMES AGENT · SOME ASSEMBLY REQUIRED · PRINTED IN PETG", f_fine, (120, 135, 138, 200), 3)
|
||||||
|
tracked(du, (PW - M - 150, PH - 78), "H-1 / 2026", f_fine, (120, 135, 138, 200), 3)
|
||||||
|
# crop marks
|
||||||
|
for (x, y, sx, sy) in [(40, 40, 1, 1), (PW - 40, 40, -1, 1), (40, PH - 40, 1, -1), (PW - 40, PH - 40, -1, -1)]:
|
||||||
|
du.line([(x, y), (x + 34 * sx, y)], fill=(110, 182, 188, 140), width=2)
|
||||||
|
du.line([(x, y), (x, y + 34 * sy)], fill=(110, 182, 188, 140), width=2)
|
||||||
|
|
||||||
|
def over(base, layer, gain=1.0):
|
||||||
|
a = np.asarray(layer).astype(np.float32) / 255
|
||||||
|
return base * (1 - a[..., 3:4] * gain) + a[..., :3] * a[..., 3:4] * gain
|
||||||
|
|
||||||
|
img += np.asarray(glow_h).astype(np.float32)[..., :3] / 255 * (np.asarray(glow_h)[..., 3:4] / 255) * np.array([1.0, 0.62, 0.22]) * 0.9
|
||||||
|
img = over(img, head_layer)
|
||||||
|
img = over(img, ui)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ CRT finish
|
||||||
|
# gentle tone curve and split toning: teal shadows, amber highlights
|
||||||
|
lum = img.mean(-1, keepdims=True)
|
||||||
|
img = img + (0.03 * (1 - lum) * np.array([-0.3, 0.2, 0.35])) + 0.04 * lum * np.array([0.4, 0.1, -0.3])
|
||||||
|
img = np.clip(img, 0, None)
|
||||||
|
img = img / (1 + img * 0.18) # soft shoulder
|
||||||
|
# chromatic fringe
|
||||||
|
r = np.roll(img[..., 0], 2, axis=1); b = np.roll(img[..., 2], -2, axis=1)
|
||||||
|
img = np.stack([r, img[..., 1], b], -1)
|
||||||
|
# scanlines: 3 px period, plus a slow brighter roll band
|
||||||
|
row = yy.astype(int) % 3
|
||||||
|
scan = np.where(row == 0, 0.58, np.where(row == 1, 0.92, 1.0))
|
||||||
|
band = 1 + 0.06 * np.exp(-((yy - PH * 0.33) / 90) ** 2)
|
||||||
|
img *= (scan * band)[..., None]
|
||||||
|
# grain and vignette
|
||||||
|
img += rng.normal(0, 0.018, img.shape).astype(np.float32)
|
||||||
|
vig = 1 - 0.55 * (((xx - PW / 2) / (PW * 0.75)) ** 2 + ((yy - PH * 0.52) / (PH * 0.68)) ** 2)
|
||||||
|
img *= np.clip(vig, 0.25, 1)[..., None]
|
||||||
|
out = (np.clip(img, 0, 1) ** (1 / 1.08) * 255).astype(np.uint8)
|
||||||
|
Image.fromarray(out).save(OUT)
|
||||||
|
print("wrote", OUT)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
+75406
File diff suppressed because it is too large
Load Diff
+22598
File diff suppressed because it is too large
Load Diff
+102846
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+32202
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+11986
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 6.6 MiB |
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-pi-platform.sh: confirm, without the Pi, that Ubuntu 24.04's Raspberry Pi kernel ships the
|
||||||
|
# drivers and overlays the robot needs. It downloads the current linux-modules-*-raspi package for
|
||||||
|
# arm64 (about 50 MB) and inspects it. Nothing is installed; everything happens in a temp folder.
|
||||||
|
#
|
||||||
|
# Needs: curl, gzip, awk, ar (binutils), zstd, tar
|
||||||
|
# NixOS: nix-shell -p curl binutils zstd gnutar gawk --run ./check-pi-platform.sh
|
||||||
|
# Omarchy: sudo pacman -S --needed binutils zstd && ./check-pi-platform.sh
|
||||||
|
#
|
||||||
|
# Usage: ./check-pi-platform.sh [suite] (default suite: noble-updates)
|
||||||
|
# MIRROR=<url> overrides http://ports.ubuntu.com/ubuntu-ports
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SUITE="${1:-noble-updates}"
|
||||||
|
MIRROR="${MIRROR:-http://ports.ubuntu.com/ubuntu-ports}"
|
||||||
|
|
||||||
|
for tool in curl gzip awk ar zstd tar; do
|
||||||
|
command -v "$tool" >/dev/null || { echo "Missing tool: $tool (see the header of this script)" >&2; exit 2; }
|
||||||
|
done
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
cd "$work"
|
||||||
|
|
||||||
|
echo "==> Reading the arm64 package index for $SUITE"
|
||||||
|
curl -fsSL "$MIRROR/dists/$SUITE/main/binary-arm64/Packages.gz" | gzip -dc > Packages
|
||||||
|
|
||||||
|
# field <package> <Field>: the value of Field in that package's stanza (last stanza wins)
|
||||||
|
field() {
|
||||||
|
awk -v p="$1" -v f="$2" '
|
||||||
|
/^Package: / { inpkg = ($2 == p) }
|
||||||
|
inpkg && index($0, f ": ") == 1 { v = substr($0, length(f) + 3) }
|
||||||
|
END { if (v != "") print v }' Packages
|
||||||
|
}
|
||||||
|
|
||||||
|
meta_deps="$(field linux-image-raspi Depends)"
|
||||||
|
[[ -n "$meta_deps" ]] || { echo "linux-image-raspi not found in $SUITE" >&2; exit 1; }
|
||||||
|
abi="$(grep -oE 'linux-image-[0-9][^ ,(]*-raspi' <<<"$meta_deps" | head -1 | sed 's/^linux-image-//')"
|
||||||
|
modpkg="linux-modules-$abi"
|
||||||
|
debpath="$(field "$modpkg" Filename)"
|
||||||
|
[[ -n "$debpath" ]] || { echo "$modpkg not found in $SUITE" >&2; exit 1; }
|
||||||
|
echo "==> Current Pi kernel for Ubuntu 24.04: $abi"
|
||||||
|
echo "==> Downloading $modpkg"
|
||||||
|
curl -fsSL "$MIRROR/$debpath" -o modules.deb
|
||||||
|
|
||||||
|
member="$(ar t modules.deb | grep '^data\.tar' | head -1)"
|
||||||
|
case "$member" in
|
||||||
|
*.zst) decomp=(zstd -dc) ;;
|
||||||
|
*.xz) decomp=(xz -dc) ;;
|
||||||
|
*.gz) decomp=(gzip -dc) ;;
|
||||||
|
*) echo "Unexpected package layout: $member" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
ar p modules.deb "$member" | "${decomp[@]}" | tar -t > files
|
||||||
|
ar p modules.deb "$member" | "${decomp[@]}" | tar -xO "./boot/config-$abi" > config 2>/dev/null || : > config
|
||||||
|
|
||||||
|
missing=0
|
||||||
|
# check <label> <module file name> <CONFIG_ key>
|
||||||
|
check() {
|
||||||
|
local label="$1" mod="$2" key="$3" state
|
||||||
|
if grep -qE "/${mod}\.ko(\.zst|\.xz|\.gz)?$" files; then
|
||||||
|
state="ok (module)"
|
||||||
|
elif grep -qx "${key}=y" config; then
|
||||||
|
state="ok (built in)"
|
||||||
|
else
|
||||||
|
state="MISSING"; missing=$((missing + 1))
|
||||||
|
fi
|
||||||
|
printf ' %-40s %s\n' "$label" "$state"
|
||||||
|
}
|
||||||
|
# overlay <name>
|
||||||
|
overlay() {
|
||||||
|
if grep -qE "/overlays/$1\.dtbo$" files; then
|
||||||
|
printf ' %-40s ok\n' "$1.dtbo"
|
||||||
|
else
|
||||||
|
printf ' %-40s MISSING\n' "$1.dtbo"; missing=$((missing + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Audio (BrainCraft HAT, WM8960 codec at I2C 0x1A)"
|
||||||
|
check "WM8960 codec driver" snd-soc-wm8960 CONFIG_SND_SOC_WM8960
|
||||||
|
check "Simple sound card" snd-soc-simple-card CONFIG_SND_SIMPLE_CARD
|
||||||
|
check "Pi I2S interface" snd-soc-bcm2835-i2s CONFIG_SND_BCM2835_SOC_I2S
|
||||||
|
overlay wm8960-soundcard
|
||||||
|
echo "Camera (Arducam UC-346, OV5647)"
|
||||||
|
check "OV5647 sensor driver" ov5647 CONFIG_VIDEO_OV5647
|
||||||
|
check "Pi camera receiver (Unicam)" bcm2835-unicam CONFIG_VIDEO_BCM2835_UNICAM
|
||||||
|
overlay ov5647
|
||||||
|
echo "Servos (hardware PWM on GPIO12/13)"
|
||||||
|
check "Pi PWM driver" pwm-bcm2835 CONFIG_PWM_BCM2835
|
||||||
|
overlay pwm-2chan
|
||||||
|
echo "Display (ST7789 on SPI)"
|
||||||
|
check "Pi SPI driver" spi-bcm2835 CONFIG_SPI_BCM2835
|
||||||
|
echo "USB link (gadget mode on the USB-C port)"
|
||||||
|
check "DWC2 USB controller" dwc2 CONFIG_USB_DWC2
|
||||||
|
check "Gadget framework (configfs)" libcomposite CONFIG_USB_LIBCOMPOSITE
|
||||||
|
check "NCM Ethernet function" usb_f_ncm CONFIG_USB_F_NCM
|
||||||
|
check "ECM Ethernet function (fallback)" usb_f_ecm CONFIG_USB_F_ECM
|
||||||
|
check "ACM serial function" usb_f_acm CONFIG_USB_F_ACM
|
||||||
|
overlay dwc2
|
||||||
|
|
||||||
|
echo
|
||||||
|
[[ -s config ]] || echo "Note: no kernel config in the package, so built-in drivers would show as MISSING."
|
||||||
|
if (( missing == 0 )); then
|
||||||
|
echo "Everything the robot needs is in Ubuntu's $abi kernel."
|
||||||
|
else
|
||||||
|
echo "$missing item(s) missing from $abi. Ubuntu 24.04 may not suit this robot as-is; see HANDOFF.md package 0a."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# ~/.config/pipewire/pipewire.conf.d/50-robot-tunnel.conf (on Omarchy)
|
||||||
|
#
|
||||||
|
# Makes the robot's speaker and microphone appear here as ordinary audio devices,
|
||||||
|
# "Robot speaker" and "Robot microphone". Replace ROBOT_IP with the Pi's address
|
||||||
|
# (give it a DHCP reservation on your router so it never changes).
|
||||||
|
#
|
||||||
|
# Apply with: systemctl --user restart pipewire pipewire-pulse wireplumber
|
||||||
|
|
||||||
|
context.modules = [
|
||||||
|
{ name = libpipewire-module-pulse-tunnel
|
||||||
|
args = {
|
||||||
|
tunnel.mode = sink
|
||||||
|
pulse.server.address = "tcp:ROBOT_IP:4713"
|
||||||
|
#pulse.device = "robot_ec_sink" # only with the Pi's optional echo-cancel config
|
||||||
|
pulse.latency = 200 # ms; lower is snappier but may crackle on weak Wi-Fi
|
||||||
|
reconnect.interval.ms = 5000 # keep retrying while the robot is off
|
||||||
|
stream.props = {
|
||||||
|
node.name = "robot_speaker"
|
||||||
|
node.description = "Robot speaker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{ name = libpipewire-module-pulse-tunnel
|
||||||
|
args = {
|
||||||
|
tunnel.mode = source
|
||||||
|
pulse.server.address = "tcp:ROBOT_IP:4713"
|
||||||
|
#pulse.device = "robot_ec_source" # only with the Pi's optional echo-cancel config
|
||||||
|
pulse.latency = 200
|
||||||
|
reconnect.interval.ms = 5000
|
||||||
|
stream.props = {
|
||||||
|
node.name = "robot_mic"
|
||||||
|
node.description = "Robot microphone"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Merge these into ~/.hermes/config.yaml on Omarchy.
|
||||||
|
# Put the token from the Pi's install.sh output in ~/.hermes/.env as:
|
||||||
|
# ROBOT_MCP_TOKEN=<64 hex characters>
|
||||||
|
|
||||||
|
mcp_servers:
|
||||||
|
robot:
|
||||||
|
url: "http://ROBOT_IP:8765/mcp"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer ${ROBOT_MCP_TOKEN}"
|
||||||
|
connect_timeout: 15
|
||||||
|
timeout: 30 # look() normally answers in about a second
|
||||||
|
|
||||||
|
voice:
|
||||||
|
auto_tts: true # speak replies whenever voice mode is on
|
||||||
|
# The robot's mic sits beside its own speaker. If Hermes interrupts itself,
|
||||||
|
# raise this (default 3.0) or enable the Pi's optional echo-cancel config.
|
||||||
|
barge_in_threshold_multiplier: 4.0
|
||||||
|
# If the level bar barely moves when you speak, lower this (default 200)
|
||||||
|
# or raise the mic volume on the Pi (see README).
|
||||||
|
silence_threshold: 200
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# hermes-robot: start Hermes with the robot as the default speaker and microphone,
|
||||||
|
# then put your usual devices back when Hermes exits.
|
||||||
|
#
|
||||||
|
# Install: install -Dm755 hermes-robot ~/.local/bin/hermes-robot
|
||||||
|
# Use: hermes-robot (any arguments are passed through to hermes)
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SINK=robot_speaker
|
||||||
|
SOURCE=robot_mic
|
||||||
|
|
||||||
|
has() { pactl list short "$1" | awk '{print $2}' | grep -qxF "$2"; }
|
||||||
|
if ! has sinks "$SINK" || ! has sources "$SOURCE"; then
|
||||||
|
echo "hermes-robot: the robot's audio devices aren't present." >&2
|
||||||
|
echo " Is the robot powered and on Wi-Fi? Check with: pactl list short sources" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
prev_sink="$(pactl get-default-sink)"
|
||||||
|
prev_source="$(pactl get-default-source)"
|
||||||
|
restore() {
|
||||||
|
pactl set-default-sink "$prev_sink" 2>/dev/null
|
||||||
|
pactl set-default-source "$prev_source" 2>/dev/null
|
||||||
|
}
|
||||||
|
trap restore EXIT INT TERM
|
||||||
|
|
||||||
|
pactl set-default-sink "$SINK"
|
||||||
|
pactl set-default-source "$SOURCE"
|
||||||
|
|
||||||
|
hermes "$@"
|
||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Robot setup for Raspberry Pi OS Lite (64-bit) on a Pi Zero 2 W.
|
||||||
|
# Run as your normal user (not root) from inside this "pi" folder:
|
||||||
|
#
|
||||||
|
# ./install.sh <omarchy-machine-ip> [--servos]
|
||||||
|
#
|
||||||
|
# Safe to re-run. Reboot afterwards so the I2S sound card overlay takes effect.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST_IP="${1:-}"
|
||||||
|
SERVOS=0
|
||||||
|
[[ "${2:-}" == "--servos" ]] && SERVOS=1
|
||||||
|
|
||||||
|
if [[ -z "$HOST_IP" || ! "$HOST_IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
|
||||||
|
echo "usage: $0 <omarchy-machine-ipv4> [--servos]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ $EUID -eq 0 ]]; then
|
||||||
|
echo "Run this as your normal user; it calls sudo where needed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ME="$(id -un)"
|
||||||
|
CONFIG_TXT=/boot/firmware/config.txt
|
||||||
|
[[ -f $CONFIG_TXT ]] || CONFIG_TXT=/boot/config.txt
|
||||||
|
|
||||||
|
echo "==> Installing packages"
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
python3-picamera2 python3-venv \
|
||||||
|
pipewire pipewire-pulse pipewire-alsa wireplumber libspa-0.2-modules \
|
||||||
|
pulseaudio-utils alsa-utils nftables openssl
|
||||||
|
|
||||||
|
echo "==> Enabling the I2S microphone/amplifier overlay in $CONFIG_TXT"
|
||||||
|
add_line() { grep -qxF "$1" "$CONFIG_TXT" || echo "$1" | sudo tee -a "$CONFIG_TXT" >/dev/null; }
|
||||||
|
add_line "dtparam=i2s=on"
|
||||||
|
add_line "dtoverlay=googlevoicehat-soundcard"
|
||||||
|
if [[ $SERVOS -eq 1 ]]; then
|
||||||
|
add_line "dtparam=i2c_arm=on"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Installing the MCP server to /opt/robot-mcp"
|
||||||
|
sudo mkdir -p /opt/robot-mcp
|
||||||
|
sudo chown "$ME:$ME" /opt/robot-mcp
|
||||||
|
cp "$HERE/robot_mcp_server.py" /opt/robot-mcp/
|
||||||
|
# --system-site-packages so the venv can see the apt-installed picamera2/libcamera
|
||||||
|
[[ -d /opt/robot-mcp/venv ]] || python3 -m venv --system-site-packages /opt/robot-mcp/venv
|
||||||
|
/opt/robot-mcp/venv/bin/pip install --upgrade pip >/dev/null
|
||||||
|
/opt/robot-mcp/venv/bin/pip install -r "$HERE/requirements.txt"
|
||||||
|
if [[ $SERVOS -eq 1 ]]; then
|
||||||
|
/opt/robot-mcp/venv/bin/pip install adafruit-circuitpython-servokit
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Writing /etc/robot-mcp.env"
|
||||||
|
if ! sudo test -f /etc/robot-mcp.env; then
|
||||||
|
TOKEN="$(openssl rand -hex 32)"
|
||||||
|
sudo tee /etc/robot-mcp.env >/dev/null <<EOF
|
||||||
|
ROBOT_MCP_TOKEN=$TOKEN
|
||||||
|
ROBOT_MCP_PORT=8765
|
||||||
|
ROBOT_CAM_SIZE=1280x720
|
||||||
|
ROBOT_CAM_HFLIP=0
|
||||||
|
ROBOT_CAM_VFLIP=0
|
||||||
|
ROBOT_SERVOS=$SERVOS
|
||||||
|
EOF
|
||||||
|
sudo chown root:"$ME" /etc/robot-mcp.env
|
||||||
|
sudo chmod 640 /etc/robot-mcp.env
|
||||||
|
else
|
||||||
|
sudo sed -i "s/^ROBOT_SERVOS=.*/ROBOT_SERVOS=$SERVOS/" /etc/robot-mcp.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Installing the systemd service"
|
||||||
|
sed "s/__USER__/$ME/g" "$HERE/robot-mcp.service" | sudo tee /etc/systemd/system/robot-mcp.service >/dev/null
|
||||||
|
getent group i2c >/dev/null || sudo groupadd --system i2c
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable robot-mcp.service
|
||||||
|
|
||||||
|
echo "==> Configuring PipeWire to serve audio to $HOST_IP"
|
||||||
|
mkdir -p ~/.config/pipewire/pipewire-pulse.conf.d
|
||||||
|
cp "$HERE/pipewire/50-network-server.conf" ~/.config/pipewire/pipewire-pulse.conf.d/
|
||||||
|
# Run the audio stack at boot without anyone logging in.
|
||||||
|
sudo loginctl enable-linger "$ME"
|
||||||
|
systemctl --user enable pipewire.socket pipewire-pulse.socket wireplumber.service 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "==> Restricting ports 4713 and 8765 to $HOST_IP"
|
||||||
|
sed "s/__HOST_IP__/$HOST_IP/g" "$HERE/nftables-robot.nft" | sudo tee /etc/nftables-robot.nft >/dev/null
|
||||||
|
grep -qxF 'include "/etc/nftables-robot.nft"' /etc/nftables.conf \
|
||||||
|
|| echo 'include "/etc/nftables-robot.nft"' | sudo tee -a /etc/nftables.conf >/dev/null
|
||||||
|
sudo systemctl enable nftables.service
|
||||||
|
sudo systemctl restart nftables.service
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done. Reboot now: sudo reboot"
|
||||||
|
echo
|
||||||
|
echo "Your MCP token (put this in ~/.hermes/.env on the Omarchy machine as ROBOT_MCP_TOKEN):"
|
||||||
|
sudo grep '^ROBOT_MCP_TOKEN=' /etc/robot-mcp.env | cut -d= -f2
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/sbin/nft -f
|
||||||
|
# Only your Omarchy machine may reach the robot's audio (4713) and MCP (8765) ports.
|
||||||
|
# Everything else, SSH included, is left alone. __HOST_IP__ is filled in by install.sh.
|
||||||
|
|
||||||
|
table inet robot
|
||||||
|
delete table inet robot
|
||||||
|
|
||||||
|
table inet robot {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy accept;
|
||||||
|
iif "lo" accept
|
||||||
|
ip saddr __HOST_IP__ tcp dport { 4713, 8765 } accept
|
||||||
|
tcp dport { 4713, 8765 } drop
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# ~/.config/pipewire/pipewire-pulse.conf.d/50-network-server.conf (on the Pi)
|
||||||
|
#
|
||||||
|
# Lets your Omarchy machine reach the robot's microphone and speaker over the
|
||||||
|
# network, using the PulseAudio protocol that pipewire-pulse speaks.
|
||||||
|
#
|
||||||
|
# This replaces the default address list, so "unix:native" must stay in it.
|
||||||
|
# Anyone on the LAN who can reach port 4713 could listen to the microphone;
|
||||||
|
# the nftables rules installed alongside restrict it to your machine's IP.
|
||||||
|
|
||||||
|
pulse.properties = {
|
||||||
|
server.address = [
|
||||||
|
"unix:native"
|
||||||
|
{ address = "tcp:4713" client.access = "unrestricted" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# OPTIONAL. Copy to ~/.config/pipewire/pipewire.conf.d/60-echo-cancel.conf on the Pi
|
||||||
|
# (drop the ".optional") only if the robot hears its own voice and Hermes keeps
|
||||||
|
# interrupting itself (barge-in tripped by speaker bleed).
|
||||||
|
#
|
||||||
|
# It creates an echo-cancelled mic ("robot_ec_source") and a matching speaker
|
||||||
|
# ("robot_ec_sink"). After enabling it, uncomment the pulse.device lines in the
|
||||||
|
# Omarchy tunnel config so the tunnels use these nodes instead of the raw ones.
|
||||||
|
#
|
||||||
|
# WebRTC echo cancellation costs some CPU; a Pi Zero 2 W manages it at speech rates,
|
||||||
|
# but check `top` afterwards.
|
||||||
|
|
||||||
|
context.modules = [
|
||||||
|
{ name = libpipewire-module-echo-cancel
|
||||||
|
args = {
|
||||||
|
library.name = aec/libspa-aec-webrtc
|
||||||
|
capture.props = { node.name = "robot_ec_capture" node.passive = true }
|
||||||
|
source.props = { node.name = "robot_ec_source" node.description = "Robot mic (echo-cancelled)" }
|
||||||
|
sink.props = { node.name = "robot_ec_sink" node.description = "Robot speaker (echo-cancelled)" }
|
||||||
|
playback.props = { node.name = "robot_ec_playback" node.passive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# picamera2 and libcamera come from apt (python3-picamera2), not pip.
|
||||||
|
# Written against the MCP Python SDK 2.x (MCPServer); 1.x used the older FastMCP name.
|
||||||
|
mcp>=2.3,<3
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Robot MCP server (camera and head for Hermes Agent)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=__USER__
|
||||||
|
Group=__USER__
|
||||||
|
SupplementaryGroups=video i2c
|
||||||
|
EnvironmentFile=/etc/robot-mcp.env
|
||||||
|
ExecStart=/opt/robot-mcp/venv/bin/python /opt/robot-mcp/robot_mcp_server.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Robot MCP server: runs on the Raspberry Pi and gives Hermes Agent the robot's eyes
|
||||||
|
(and, optionally, a neck).
|
||||||
|
|
||||||
|
Tools exposed
|
||||||
|
look() -> a JPEG still from the camera
|
||||||
|
turn_head(pan, tilt) -> aim the pan/tilt servos (only if ROBOT_SERVOS=1)
|
||||||
|
head_position() -> current pan/tilt (only if ROBOT_SERVOS=1)
|
||||||
|
|
||||||
|
Transport: MCP streamable HTTP at http://<pi>:<port>/mcp, guarded by a bearer token.
|
||||||
|
Audio is NOT handled here; it travels over PipeWire (see README).
|
||||||
|
|
||||||
|
Configuration is by environment variable (normally set in /etc/robot-mcp.env):
|
||||||
|
ROBOT_MCP_TOKEN required shared secret; Hermes sends "Authorization: Bearer <token>"
|
||||||
|
ROBOT_MCP_PORT 8765
|
||||||
|
ROBOT_MCP_BIND 0.0.0.0
|
||||||
|
ROBOT_CAM_SIZE 1280x720 still resolution handed to the model
|
||||||
|
ROBOT_CAM_QUALITY 85 JPEG quality
|
||||||
|
ROBOT_CAM_HFLIP 0 set 1 if the camera is mounted mirrored
|
||||||
|
ROBOT_CAM_VFLIP 0 set 1 (with HFLIP=1) if mounted upside down
|
||||||
|
ROBOT_SERVOS 0 set 1 to enable turn_head via a PCA9685 board
|
||||||
|
ROBOT_PAN_CHANNEL 0
|
||||||
|
ROBOT_TILT_CHANNEL 1
|
||||||
|
ROBOT_PAN_LIMIT 80 degrees either side of centre
|
||||||
|
ROBOT_TILT_LIMIT 45
|
||||||
|
ROBOT_PAN_INVERT 0 set 1 if "positive = left" comes out backwards
|
||||||
|
ROBOT_TILT_INVERT 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from mcp.server.mcpserver import Image, MCPServer
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
|
||||||
|
log = logging.getLogger("robot-mcp")
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
|
||||||
|
def env_flag(name: str, default: str = "0") -> bool:
|
||||||
|
return os.environ.get(name, default).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def env_int(name: str, default: int) -> int:
|
||||||
|
return int(os.environ.get(name, str(default)))
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = os.environ.get("ROBOT_MCP_TOKEN", "").strip()
|
||||||
|
PORT = env_int("ROBOT_MCP_PORT", 8765)
|
||||||
|
BIND = os.environ.get("ROBOT_MCP_BIND", "0.0.0.0")
|
||||||
|
CAM_W, CAM_H = (int(v) for v in os.environ.get("ROBOT_CAM_SIZE", "1280x720").lower().split("x"))
|
||||||
|
CAM_QUALITY = env_int("ROBOT_CAM_QUALITY", 85)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- camera
|
||||||
|
|
||||||
|
class Camera:
|
||||||
|
"""Keeps Picamera2 running so a still is ready in well under a second."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
from libcamera import Transform # type: ignore[import-not-found]
|
||||||
|
from picamera2 import Picamera2 # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.cam = Picamera2()
|
||||||
|
config = self.cam.create_still_configuration(
|
||||||
|
main={"size": (CAM_W, CAM_H)},
|
||||||
|
transform=Transform(hflip=env_flag("ROBOT_CAM_HFLIP"), vflip=env_flag("ROBOT_CAM_VFLIP")),
|
||||||
|
buffer_count=2,
|
||||||
|
)
|
||||||
|
self.cam.configure(config)
|
||||||
|
self.cam.options["quality"] = CAM_QUALITY
|
||||||
|
self.has_autofocus = "AfMode" in self.cam.camera_controls
|
||||||
|
self.cam.start()
|
||||||
|
time.sleep(1.0) # let auto-exposure settle once at startup
|
||||||
|
log.info("camera ready at %dx%d (autofocus: %s)", CAM_W, CAM_H, self.has_autofocus)
|
||||||
|
|
||||||
|
def snapshot(self) -> bytes:
|
||||||
|
with self._lock:
|
||||||
|
if self.has_autofocus:
|
||||||
|
# Camera Module 3: run one focus sweep so whatever is in front is sharp.
|
||||||
|
self.cam.autofocus_cycle()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
self.cam.capture_file(buf, format="jpeg")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- servos
|
||||||
|
|
||||||
|
class Head:
|
||||||
|
"""Pan/tilt on a PCA9685 servo board. Angles are degrees from centre."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
from adafruit_servokit import ServoKit # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.kit = ServoKit(channels=16)
|
||||||
|
self.pan_ch = env_int("ROBOT_PAN_CHANNEL", 0)
|
||||||
|
self.tilt_ch = env_int("ROBOT_TILT_CHANNEL", 1)
|
||||||
|
self.pan_limit = env_int("ROBOT_PAN_LIMIT", 80)
|
||||||
|
self.tilt_limit = env_int("ROBOT_TILT_LIMIT", 45)
|
||||||
|
self.pan_sign = -1 if env_flag("ROBOT_PAN_INVERT") else 1
|
||||||
|
self.tilt_sign = -1 if env_flag("ROBOT_TILT_INVERT") else 1
|
||||||
|
self.pan = 0.0
|
||||||
|
self.tilt = 0.0
|
||||||
|
self.move(0.0, 0.0)
|
||||||
|
log.info("servos ready (pan ch %d, tilt ch %d)", self.pan_ch, self.tilt_ch)
|
||||||
|
|
||||||
|
def move(self, pan: float, tilt: float) -> tuple[float, float]:
|
||||||
|
pan = max(-self.pan_limit, min(self.pan_limit, pan))
|
||||||
|
tilt = max(-self.tilt_limit, min(self.tilt_limit, tilt))
|
||||||
|
with self._lock:
|
||||||
|
self.kit.servo[self.pan_ch].angle = 90 + self.pan_sign * pan
|
||||||
|
self.kit.servo[self.tilt_ch].angle = 90 + self.tilt_sign * tilt
|
||||||
|
self.pan, self.tilt = pan, tilt
|
||||||
|
time.sleep(0.4) # let the servos arrive and stop jittering before any photo
|
||||||
|
return pan, tilt
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- MCP server
|
||||||
|
|
||||||
|
mcp = MCPServer(
|
||||||
|
"robot",
|
||||||
|
instructions=(
|
||||||
|
"This server is a small physical robot on the user's desk. "
|
||||||
|
"Call look() whenever you need to see what is in front of it; the image is current. "
|
||||||
|
"If turn_head is available, aim first and then look."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
camera: Camera | None = None
|
||||||
|
head: Head | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def look() -> Image:
|
||||||
|
"""Take a photograph with the robot's camera and return it. Use this to see the room,
|
||||||
|
the desk, the person, or anything the user asks you to look at."""
|
||||||
|
if camera is None:
|
||||||
|
raise RuntimeError("camera is not available on the robot")
|
||||||
|
t0 = time.monotonic()
|
||||||
|
data = camera.snapshot()
|
||||||
|
log.info("look(): %d bytes in %.2fs", len(data), time.monotonic() - t0)
|
||||||
|
return Image(data=data, format="jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
def register_head_tools() -> None:
|
||||||
|
@mcp.tool()
|
||||||
|
def turn_head(pan: float = 0.0, tilt: float = 0.0) -> str:
|
||||||
|
"""Aim the robot's head (and so its camera). Angles are in degrees from straight ahead.
|
||||||
|
pan: positive turns to the robot's left, negative to its right.
|
||||||
|
tilt: positive looks up, negative looks down.
|
||||||
|
Values beyond the mechanical limits are clamped. Call look() afterwards to see."""
|
||||||
|
assert head is not None
|
||||||
|
p, t = head.move(pan, tilt)
|
||||||
|
return f"head now at pan {p:+.0f} degrees, tilt {t:+.0f} degrees"
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def head_position() -> str:
|
||||||
|
"""Report where the robot's head is currently pointing."""
|
||||||
|
assert head is not None
|
||||||
|
return f"pan {head.pan:+.0f} degrees, tilt {head.tilt:+.0f} degrees"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- auth wrapper
|
||||||
|
|
||||||
|
class BearerAuth:
|
||||||
|
"""Minimal ASGI middleware: reject any HTTP request without the right bearer token.
|
||||||
|
Lifespan events pass straight through so the MCP session manager starts properly."""
|
||||||
|
|
||||||
|
def __init__(self, app, token: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.expected = f"Bearer {token}".encode()
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] == "http":
|
||||||
|
supplied = dict(scope.get("headers") or []).get(b"authorization", b"")
|
||||||
|
if not hmac.compare_digest(supplied, self.expected):
|
||||||
|
await send({"type": "http.response.start", "status": 401,
|
||||||
|
"headers": [(b"content-type", b"text/plain"),
|
||||||
|
(b"www-authenticate", b"Bearer")]})
|
||||||
|
await send({"type": "http.response.body", "body": b"unauthorized\n"})
|
||||||
|
return
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
def build_app():
|
||||||
|
app = mcp.streamable_http_app(
|
||||||
|
host=BIND,
|
||||||
|
# Bearer auth already stops drive-by requests; DNS-rebinding checks would only
|
||||||
|
# get in the way of reaching the Pi by IP or by robot.local.
|
||||||
|
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||||
|
)
|
||||||
|
return BearerAuth(app, TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
global camera, head
|
||||||
|
|
||||||
|
if len(TOKEN) < 16:
|
||||||
|
sys.exit("ROBOT_MCP_TOKEN is missing or too short (generate one with: openssl rand -hex 32)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
camera = Camera()
|
||||||
|
except Exception as exc: # keep serving servos even if the camera ribbon is loose
|
||||||
|
log.error("camera failed to start: %s", exc)
|
||||||
|
|
||||||
|
if env_flag("ROBOT_SERVOS"):
|
||||||
|
try:
|
||||||
|
head = Head()
|
||||||
|
register_head_tools()
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("servos failed to start, turn_head disabled: %s", exc)
|
||||||
|
|
||||||
|
log.info("serving MCP on http://%s:%d/mcp", BIND, PORT)
|
||||||
|
uvicorn.run(build_app(), host=BIND, port=PORT, log_level="warning")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-pi-platform.sh: confirm, without the Pi, that Ubuntu 24.04's Raspberry Pi kernel ships the
|
||||||
|
# drivers and overlays the robot needs. It downloads the current linux-modules-*-raspi package for
|
||||||
|
# arm64 (about 50 MB) and inspects it. Nothing is installed; everything happens in a temp folder.
|
||||||
|
#
|
||||||
|
# Needs: curl, gzip, awk, ar (binutils), zstd, tar
|
||||||
|
# NixOS: nix-shell -p curl binutils zstd gnutar gawk --run ./check-pi-platform.sh
|
||||||
|
# Omarchy: sudo pacman -S --needed binutils zstd && ./check-pi-platform.sh
|
||||||
|
#
|
||||||
|
# Usage: ./check-pi-platform.sh [suite] (default suite: noble-updates)
|
||||||
|
# MIRROR=<url> overrides http://ports.ubuntu.com/ubuntu-ports
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SUITE="${1:-noble-updates}"
|
||||||
|
MIRROR="${MIRROR:-http://ports.ubuntu.com/ubuntu-ports}"
|
||||||
|
|
||||||
|
for tool in curl gzip awk ar zstd tar; do
|
||||||
|
command -v "$tool" >/dev/null || { echo "Missing tool: $tool (see the header of this script)" >&2; exit 2; }
|
||||||
|
done
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
cd "$work"
|
||||||
|
|
||||||
|
echo "==> Reading the arm64 package index for $SUITE"
|
||||||
|
curl -fsSL "$MIRROR/dists/$SUITE/main/binary-arm64/Packages.gz" | gzip -dc > Packages
|
||||||
|
|
||||||
|
# field <package> <Field>: the value of Field in that package's stanza (last stanza wins)
|
||||||
|
field() {
|
||||||
|
awk -v p="$1" -v f="$2" '
|
||||||
|
/^Package: / { inpkg = ($2 == p) }
|
||||||
|
inpkg && index($0, f ": ") == 1 { v = substr($0, length(f) + 3) }
|
||||||
|
END { if (v != "") print v }' Packages
|
||||||
|
}
|
||||||
|
|
||||||
|
meta_deps="$(field linux-image-raspi Depends)"
|
||||||
|
[[ -n "$meta_deps" ]] || { echo "linux-image-raspi not found in $SUITE" >&2; exit 1; }
|
||||||
|
abi="$(grep -oE 'linux-image-[0-9][^ ,(]*-raspi' <<<"$meta_deps" | head -1 | sed 's/^linux-image-//')"
|
||||||
|
modpkg="linux-modules-$abi"
|
||||||
|
debpath="$(field "$modpkg" Filename)"
|
||||||
|
[[ -n "$debpath" ]] || { echo "$modpkg not found in $SUITE" >&2; exit 1; }
|
||||||
|
echo "==> Current Pi kernel for Ubuntu 24.04: $abi"
|
||||||
|
echo "==> Downloading $modpkg"
|
||||||
|
curl -fsSL "$MIRROR/$debpath" -o modules.deb
|
||||||
|
|
||||||
|
member="$(ar t modules.deb | grep '^data\.tar' | head -1)"
|
||||||
|
case "$member" in
|
||||||
|
*.zst) decomp=(zstd -dc) ;;
|
||||||
|
*.xz) decomp=(xz -dc) ;;
|
||||||
|
*.gz) decomp=(gzip -dc) ;;
|
||||||
|
*) echo "Unexpected package layout: $member" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
ar p modules.deb "$member" | "${decomp[@]}" | tar -t > files
|
||||||
|
ar p modules.deb "$member" | "${decomp[@]}" | tar -xO "./boot/config-$abi" > config 2>/dev/null || : > config
|
||||||
|
|
||||||
|
missing=0
|
||||||
|
# check <label> <module file name> <CONFIG_ key>
|
||||||
|
check() {
|
||||||
|
local label="$1" mod="$2" key="$3" state
|
||||||
|
if grep -qE "/${mod}\.ko(\.zst|\.xz|\.gz)?$" files; then
|
||||||
|
state="ok (module)"
|
||||||
|
elif grep -qx "${key}=y" config; then
|
||||||
|
state="ok (built in)"
|
||||||
|
else
|
||||||
|
state="MISSING"; missing=$((missing + 1))
|
||||||
|
fi
|
||||||
|
printf ' %-40s %s\n' "$label" "$state"
|
||||||
|
}
|
||||||
|
# overlay <name>
|
||||||
|
overlay() {
|
||||||
|
if grep -qE "/overlays/$1\.dtbo$" files; then
|
||||||
|
printf ' %-40s ok\n' "$1.dtbo"
|
||||||
|
else
|
||||||
|
printf ' %-40s MISSING\n' "$1.dtbo"; missing=$((missing + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Audio (BrainCraft HAT, WM8960 codec at I2C 0x1A)"
|
||||||
|
check "WM8960 codec driver" snd-soc-wm8960 CONFIG_SND_SOC_WM8960
|
||||||
|
check "Simple sound card" snd-soc-simple-card CONFIG_SND_SIMPLE_CARD
|
||||||
|
check "Pi I2S interface" snd-soc-bcm2835-i2s CONFIG_SND_BCM2835_SOC_I2S
|
||||||
|
overlay wm8960-soundcard
|
||||||
|
echo "Camera (Arducam UC-346, OV5647)"
|
||||||
|
check "OV5647 sensor driver" ov5647 CONFIG_VIDEO_OV5647
|
||||||
|
check "Pi camera receiver (Unicam)" bcm2835-unicam CONFIG_VIDEO_BCM2835_UNICAM
|
||||||
|
overlay ov5647
|
||||||
|
echo "Servos (hardware PWM on GPIO12/13)"
|
||||||
|
check "Pi PWM driver" pwm-bcm2835 CONFIG_PWM_BCM2835
|
||||||
|
overlay pwm-2chan
|
||||||
|
echo "Display (ST7789 on SPI)"
|
||||||
|
check "Pi SPI driver" spi-bcm2835 CONFIG_SPI_BCM2835
|
||||||
|
echo "USB link (gadget mode on the USB-C port)"
|
||||||
|
check "DWC2 USB controller" dwc2 CONFIG_USB_DWC2
|
||||||
|
check "Gadget framework (configfs)" libcomposite CONFIG_USB_LIBCOMPOSITE
|
||||||
|
check "NCM Ethernet function" usb_f_ncm CONFIG_USB_F_NCM
|
||||||
|
check "ECM Ethernet function (fallback)" usb_f_ecm CONFIG_USB_F_ECM
|
||||||
|
check "ACM serial function" usb_f_acm CONFIG_USB_F_ACM
|
||||||
|
overlay dwc2
|
||||||
|
|
||||||
|
echo
|
||||||
|
[[ -s config ]] || echo "Note: no kernel config in the package, so built-in drivers would show as MISSING."
|
||||||
|
if (( missing == 0 )); then
|
||||||
|
echo "Everything the robot needs is in Ubuntu's $abi kernel."
|
||||||
|
else
|
||||||
|
echo "$missing item(s) missing from $abi. Ubuntu 24.04 may not suit this robot as-is; see HANDOFF.md package 0a."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user