Initial commit

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:28:01 -07:00
co-authored by Claude Sonnet 5.5
commit 4bd4729b46
44 changed files with 519803 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.remember/
__pycache__/
*.pyc
*.orig
*.rej
.venv/
+18
View File
@@ -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
View File
@@ -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).
+205
View File
@@ -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
```
+789
View File
@@ -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>
+102
View File
@@ -0,0 +1,102 @@
# Hermes robot enclosure
![Assembly](renders/assembly.png)
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.
+512
View File
@@ -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();
+20
View File
@@ -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
+198
View File
@@ -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
+399
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+22598
View File
File diff suppressed because it is too large Load Diff
+102846
View File
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11986
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

+108
View File
@@ -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
+36
View File
@@ -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"
}
}
}
]
+20
View File
@@ -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
+30
View File
@@ -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
View File
@@ -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
+15
View File
@@ -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
}
}
+15
View File
@@ -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" }
]
}
+22
View File
@@ -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 }
}
}
]
+3
View File
@@ -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
+17
View File
@@ -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
+229
View File
@@ -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()
+108
View File
@@ -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