diff --git a/CONTEXT.md b/CONTEXT.md index 65e2cd0..1570822 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,7 +12,8 @@ printed enclosure, speakers. Work until those arrive is limited to what the Pi, - Ubuntu 24.04.5 server, kernel `6.8.0-1065-raspi` (fully upgraded), flashed from `talos`. User `hermes` (key-only; passwordless sudo), SSH keys for `kyle@vade` (talos) and `kyle@straylight`. - On Wi-Fi at `192.168.88.152` (MAC `d8:3a:dd:0d:66:f5`); `eth0` is not cabled. The owner says it has - a static IP on the rack switch; that address is not recorded here yet. `avahi-daemon` is + a static IP on the rack switch; that address is not recorded here yet. **USB link: `10.55.0.2`** + (see package 0b below); `ssh hermes@10.55.0.2` works from talos. `avahi-daemon` is installed, but mDNS is off in `systemd-resolved` on talos, so `hermes-bot.local` doesn't resolve there; use the IP. - **cloud-init applies `network-config` and `user-data` on the first boot only.** To change Wi-Fi @@ -40,6 +41,26 @@ printed enclosure, speakers. Work until those arrive is limited to what the Pi, - Not done: audio (`wm8960-soundcard` overlay + mixer), camera (`cam -l`, `camera_ros`), PWM (waits for servos). Ask before rebooting the Pi. +## Package 0b (USB link): Pi side and talos side working + +- Pi: `dtoverlay=dwc2,dr_mode=peripheral`; `robot-usb-gadget.service` builds a configfs gadget + (NCM `usb0` + ACM `ttyGS0`, host MAC `02:68:65:72:6d:01`, robot MAC `02:68:65:72:6d:02`) before + networking; `serial-getty@ttyGS0` is enabled; `/etc/netplan/60-usb0.yaml` gives `usb0` + `10.55.0.2/30` with no gateway, so the default route stays on Wi-Fi. Installed by + `pi/install-usb-gadget.sh` (a backup of `config.txt` is `config.txt.bak-usbgadget`). NCM works on + the Pi 4 with kernel 1065; ECM was not needed. +- **netplan gotcha:** match the gadget interface by `name: usb0`. A `macaddress:` match becomes + `PermanentMACAddress=` in networkd, which a gadget interface lacks, so it stays unmanaged. +- talos (Omarchy, NetworkManager): `host/omarchy/robot-usb-link.sh` (needs sudo) adds profile + `hermes-robot-usb`: `10.55.0.1/30` on the interface with MAC `02:68:65:72:6d:01`, priority 100, + never-default. Verified from talos: ping ~2.4 ms, SSH over the link. +- Not yet verified: serial login prompt (talos's `/dev/ttyACM0` is `root:uucp`; use + `sudo screen /dev/ttyACM0 115200`). The `hermes` account has no password, so the console shows a + login prompt but cannot log in until the owner sets one; no autologin by design. Also not done: + the nftables rewrite, the Pi booting with Wi-Fi off, replug and second-host recovery, + `get_throttled` after a servo sweep on the splitter, the NixOS snippet, and straylight/other-host + testing. Currently the Pi is powered from talos's USB port (bench test, no servos). + ## Face (`ros/hermes_face`) - `render.py`: numpy port of `docs/face-prototype.html`, **flat face only** (head and lowpoly not @@ -55,7 +76,8 @@ printed enclosure, speakers. Work until those arrive is limited to what the Pi, `focus`, `jitter`, `intensity`, `initial_state`. - `demo.py`: no ROS. `python -m hermes_face.demo --sequence` cycles the states on the panel; `--png-dir DIR` writes a PNG per state without hardware. -- Tests: `python3 -m pytest test` in `ros/hermes_face` (23 pass on the Pi). +- Tests: `python3 -m pytest test` in `ros/hermes_face` (25 pass on the Pi). Renderer parameters + (`focus`, `persistence_ms`, `intensity`, `jitter`) are clamped; `fps` is clamped to 1-60. - Measured on the Pi 4: ~9 ms per frame in isolation. With the CPU at full clock: 24% of a core at 20 fps and 40% at 30 fps (target was under 40% at 20 fps or more). Under `ondemand` the same load reads 40% at 20 fps and ~67% at 30 fps. No C++ rewrite was needed. @@ -66,10 +88,10 @@ printed enclosure, speakers. Work until those arrive is limited to what the Pi, ## Next -1. Package 0b: USB gadget Ethernet (`dwc2` peripheral, NCM + ACM, `usb0` at `10.55.0.2/30`). Needs a - reboot and a USB-C data cable to a host; the splitter has not arrived, so a bench test would - power the Pi from the host's port (fine without servos). Keep Wi-Fi up throughout. -2. Audio (package 1), camera (package 3), then the rest of the packages in `HANDOFF.md`. +1. Finish package 0b: nftables rewrite (accept 4713/8765 only on `usb0` from `10.55.0.1`), Wi-Fi-off + boot test, replug recovery, NixOS snippet in `host/nixos/`. +2. Audio (package 1; needs the `wm8960-soundcard` overlay and a reboot), camera (package 3), then + the rest of the packages in `HANDOFF.md`. ## Repository diff --git a/host/omarchy/robot-usb-link.sh b/host/omarchy/robot-usb-link.sh new file mode 100755 index 0000000..32bff51 --- /dev/null +++ b/host/omarchy/robot-usb-link.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Host side of the robot's USB link on Omarchy (NetworkManager): 10.55.0.1/30 on the gadget's +# interface, matched by its fixed MAC so any USB port and either machine works. Re-runnable. +# No gateway and never-default, so the host's traffic is never routed through the robot. +set -euo pipefail + +NAME=hermes-robot-usb +HOST_MAC=02:68:65:72:6d:01 # must match HOST_MAC in pi/usb-gadget.sh + +nmcli connection delete "$NAME" >/dev/null 2>&1 || true +nmcli connection add type ethernet con-name "$NAME" ifname '*' \ + ethernet.mac-address "$HOST_MAC" \ + ipv4.method manual ipv4.addresses 10.55.0.1/30 ipv4.never-default yes \ + ipv6.method disabled \ + connection.autoconnect yes connection.autoconnect-priority 100 +echo "profile '$NAME' added; the robot is at 10.55.0.2" diff --git a/pi/install-usb-gadget.sh b/pi/install-usb-gadget.sh new file mode 100755 index 0000000..1a551cb --- /dev/null +++ b/pi/install-usb-gadget.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Pi side of the USB link (HANDOFF package 0b). Run as root from this directory. Re-runnable. +# Needs a reboot afterwards for the dwc2 mode change to take effect. +set -euo pipefail +cd "$(dirname "$0")" + +install -m 755 usb-gadget.sh /usr/local/sbin/robot-usb-gadget +install -m 644 robot-usb-gadget.service /etc/systemd/system/robot-usb-gadget.service +install -m 600 netplan-usb0.yaml /etc/netplan/60-usb0.yaml +echo libcomposite > /etc/modules-load.d/robot-usb-gadget.conf + +cfg=/boot/firmware/config.txt +[ -e "$cfg.bak-usbgadget" ] || cp "$cfg" "$cfg.bak-usbgadget" +if grep -qx 'dtoverlay=dwc2' "$cfg"; then + sed -i 's/^dtoverlay=dwc2$/dtoverlay=dwc2,dr_mode=peripheral/' "$cfg" +elif ! grep -qx 'dtoverlay=dwc2,dr_mode=peripheral' "$cfg"; then + printf '\n[all]\ndtoverlay=dwc2,dr_mode=peripheral\n' >> "$cfg" +fi + +netplan generate +systemctl daemon-reload +systemctl enable robot-usb-gadget.service +systemctl enable serial-getty@ttyGS0.service +echo "installed; reboot to apply" diff --git a/pi/netplan-usb0.yaml b/pi/netplan-usb0.yaml new file mode 100644 index 0000000..bef8a7a --- /dev/null +++ b/pi/netplan-usb0.yaml @@ -0,0 +1,14 @@ +# The robot's end of the USB link: /etc/netplan/60-usb0.yaml (mode 600). +# Matched by name: the kernel names the gadget's NCM interface usb0. (A MAC match does not work: +# netplan matches the *permanent* MAC, which a gadget interface does not have.) No gateway, so the +# robot never routes its internet traffic through the host; Wi-Fi stays the way out. +network: + version: 2 + ethernets: + usb0: + match: + name: usb0 + addresses: [10.55.0.2/30] + dhcp4: false + dhcp6: false + optional: true diff --git a/pi/robot-usb-gadget.service b/pi/robot-usb-gadget.service new file mode 100644 index 0000000..f140273 --- /dev/null +++ b/pi/robot-usb-gadget.service @@ -0,0 +1,14 @@ +[Unit] +Description=Robot USB gadget (NCM Ethernet + ACM serial) +After=sys-kernel-config.mount systemd-modules-load.service +Before=network-pre.target +Wants=network-pre.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/sbin/robot-usb-gadget start +ExecStop=/usr/local/sbin/robot-usb-gadget stop + +[Install] +WantedBy=multi-user.target diff --git a/pi/usb-gadget.sh b/pi/usb-gadget.sh new file mode 100755 index 0000000..70e58c3 --- /dev/null +++ b/pi/usb-gadget.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Composite USB gadget for the robot, via configfs: NCM Ethernet (usb0) + ACM serial (ttyGS0). +# Installed as /usr/local/sbin/robot-usb-gadget and run by robot-usb-gadget.service. +# robot-usb-gadget start | stop +set -euo pipefail + +G=/sys/kernel/config/usb_gadget/hermes +HOST_MAC=02:68:65:72:6d:01 # the host's end of the link (locally administered, spells "herm") +DEV_MAC=02:68:65:72:6d:02 # the robot's end (usb0) + +stop() { + [ -d "$G" ] || return 0 + cd "$G" + echo "" > UDC 2>/dev/null || true + rm -f configs/c.1/ncm.usb0 configs/c.1/acm.usb0 + rmdir configs/c.1/strings/0x409 configs/c.1 functions/ncm.usb0 functions/acm.usb0 strings/0x409 2>/dev/null || true + cd / + rmdir "$G" 2>/dev/null || true +} + +start() { + modprobe libcomposite + mountpoint -q /sys/kernel/config || mount -t configfs none /sys/kernel/config + stop + udc= + for _ in $(seq 40); do # dwc2 can take a moment to register after boot + udc=$(ls /sys/class/udc 2>/dev/null | head -n1 || true) + [ -n "$udc" ] && break + sleep 0.5 + done + [ -n "$udc" ] || { echo "robot-usb-gadget: no UDC (is dtoverlay=dwc2,dr_mode=peripheral set?)" >&2; exit 1; } + + mkdir -p "$G" + cd "$G" + echo 0x1d6b > idVendor # Linux Foundation + echo 0x0104 > idProduct # multifunction composite gadget + echo 0x0100 > bcdDevice + echo 0x0200 > bcdUSB + echo 0xEF > bDeviceClass # composite with interface association descriptors + echo 0x02 > bDeviceSubClass + echo 0x01 > bDeviceProtocol + mkdir -p strings/0x409 + echo "$(awk '/^Serial/ {print $3}' /proc/cpuinfo)" > strings/0x409/serialnumber + echo "Hermes" > strings/0x409/manufacturer + echo "Hermes robot" > strings/0x409/product + + mkdir -p configs/c.1/strings/0x409 + echo "NCM + ACM" > configs/c.1/strings/0x409/configuration + echo 250 > configs/c.1/MaxPower + + mkdir -p functions/ncm.usb0 + echo "$HOST_MAC" > functions/ncm.usb0/host_addr + echo "$DEV_MAC" > functions/ncm.usb0/dev_addr + mkdir -p functions/acm.usb0 + ln -s functions/ncm.usb0 configs/c.1/ + ln -s functions/acm.usb0 configs/c.1/ + + echo "$udc" > UDC +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + *) echo "usage: $0 start|stop" >&2; exit 2 ;; +esac diff --git a/ros/hermes_face/hermes_face/face_node.py b/ros/hermes_face/hermes_face/face_node.py index 2354e3e..d544678 100644 --- a/ros/hermes_face/hermes_face/face_node.py +++ b/ros/hermes_face/hermes_face/face_node.py @@ -20,7 +20,7 @@ class FaceNode(Node): def __init__(self): super().__init__('hermes_face') param = lambda name, default: self.declare_parameter(name, default).value # noqa: E731 - self.fps = float(param('fps', 30.0)) + self.fps = min(60.0, max(1.0, float(param('fps', 30.0)))) # a timer period of 1/fps output = param('output', 'st7789') # 'st7789' or 'none' (render only) self.pan_joint = param('pan_joint', 'pan_joint') self.tilt_joint = param('tilt_joint', 'tilt_joint') diff --git a/ros/hermes_face/hermes_face/render.py b/ros/hermes_face/hermes_face/render.py index 21e0d0c..d0d9d28 100644 --- a/ros/hermes_face/hermes_face/render.py +++ b/ros/hermes_face/hermes_face/render.py @@ -125,12 +125,16 @@ class FaceRenderer: if rotation not in (0, 90, 180, 270): raise ValueError(f'rotation must be 0, 90, 180 or 270, not {rotation!r}') self.rotation = rotation # output turned this many degrees counter-clockwise + if phosphor not in PHOSPHORS: + raise ValueError(f'unknown phosphor {phosphor!r}; known: {", ".join(PHOSPHORS)}') self.rgb = PHOSPHORS[phosphor] self.style = style - self.persist_ms = persist_ms - self.intensity = intensity - self.focus = focus - self.jitter = jitter + # Clamped, because these arrive as node parameters: focus sets a blur radius, and + # persistence divides. + self.persist_ms = min(5000.0, max(5.0, float(persist_ms))) + self.intensity = min(4.0, max(0.0, float(intensity))) + self.focus = min(1.0, max(0.0, float(focus))) + self.jitter = min(5.0, max(0.0, float(jitter))) self.rng = np.random.default_rng(seed) self.buf = np.zeros((H, W), np.float32) diff --git a/ros/hermes_face/test/test_render.py b/ros/hermes_face/test/test_render.py index 665ac63..8b06d75 100644 --- a/ros/hermes_face/test/test_render.py +++ b/ros/hermes_face/test/test_render.py @@ -99,3 +99,14 @@ def test_rotation_turns_the_frame_counter_clockwise(rotation): def test_bad_rotation_rejected(): with pytest.raises(ValueError): FaceRenderer(rotation=45) + + +def test_wild_parameters_are_clamped(): + r = FaceRenderer(focus=1e9, persist_ms=0, intensity=-3, jitter=1e6, seed=0) + assert (r.focus, r.persist_ms, r.intensity, r.jitter) == (1.0, 5.0, 0.0, 5.0) + assert run(r, 1)[0].shape == (H, W, 3) + + +def test_unknown_phosphor_rejected(): + with pytest.raises(ValueError): + FaceRenderer(phosphor='P99')