Bring up the USB gadget link (package 0b, Pi side and talos side)
The Pi presents NCM Ethernet and ACM serial from a configfs gadget, started by robot-usb-gadget.service before networking. usb0 gets 10.55.0.2/30 with no gateway through netplan, matched by interface name: a MAC match turns into PermanentMACAddress= in networkd, which a gadget interface does not have, so it stayed unmanaged. talos reaches the Pi at 10.55.0.2 (ping ~2.4 ms, SSH works) through a NetworkManager profile, host/omarchy/robot-usb-link.sh, matched by the gadget's fixed host MAC. Also clamp the face's tunable parameters (focus sets a blur radius, persistence divides) and fps, after a review flagged unvalidated node parameters. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
+28
-6
@@ -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
|
- 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`.
|
`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
|
- 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
|
installed, but mDNS is off in `systemd-resolved` on talos, so `hermes-bot.local` doesn't resolve
|
||||||
there; use the IP.
|
there; use the IP.
|
||||||
- **cloud-init applies `network-config` and `user-data` on the first boot only.** To change Wi-Fi
|
- **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
|
- Not done: audio (`wm8960-soundcard` overlay + mixer), camera (`cam -l`, `camera_ros`), PWM
|
||||||
(waits for servos). Ask before rebooting the Pi.
|
(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`)
|
## Face (`ros/hermes_face`)
|
||||||
|
|
||||||
- `render.py`: numpy port of `docs/face-prototype.html`, **flat face only** (head and lowpoly not
|
- `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`.
|
`focus`, `jitter`, `intensity`, `initial_state`.
|
||||||
- `demo.py`: no ROS. `python -m hermes_face.demo --sequence` cycles the states on the panel;
|
- `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.
|
`--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
|
- 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
|
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.
|
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
|
## Next
|
||||||
|
|
||||||
1. Package 0b: USB gadget Ethernet (`dwc2` peripheral, NCM + ACM, `usb0` at `10.55.0.2/30`). Needs a
|
1. Finish package 0b: nftables rewrite (accept 4713/8765 only on `usb0` from `10.55.0.1`), Wi-Fi-off
|
||||||
reboot and a USB-C data cable to a host; the splitter has not arrived, so a bench test would
|
boot test, replug recovery, NixOS snippet in `host/nixos/`.
|
||||||
power the Pi from the host's port (fine without servos). Keep Wi-Fi up throughout.
|
2. Audio (package 1; needs the `wm8960-soundcard` overlay and a reboot), camera (package 3), then
|
||||||
2. Audio (package 1), camera (package 3), then the rest of the packages in `HANDOFF.md`.
|
the rest of the packages in `HANDOFF.md`.
|
||||||
|
|
||||||
## Repository
|
## Repository
|
||||||
|
|
||||||
|
|||||||
Executable
+16
@@ -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"
|
||||||
Executable
+24
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+65
@@ -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
|
||||||
@@ -20,7 +20,7 @@ class FaceNode(Node):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__('hermes_face')
|
super().__init__('hermes_face')
|
||||||
param = lambda name, default: self.declare_parameter(name, default).value # noqa: E731
|
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)
|
output = param('output', 'st7789') # 'st7789' or 'none' (render only)
|
||||||
self.pan_joint = param('pan_joint', 'pan_joint')
|
self.pan_joint = param('pan_joint', 'pan_joint')
|
||||||
self.tilt_joint = param('tilt_joint', 'tilt_joint')
|
self.tilt_joint = param('tilt_joint', 'tilt_joint')
|
||||||
|
|||||||
@@ -125,12 +125,16 @@ class FaceRenderer:
|
|||||||
if rotation not in (0, 90, 180, 270):
|
if rotation not in (0, 90, 180, 270):
|
||||||
raise ValueError(f'rotation must be 0, 90, 180 or 270, not {rotation!r}')
|
raise ValueError(f'rotation must be 0, 90, 180 or 270, not {rotation!r}')
|
||||||
self.rotation = rotation # output turned this many degrees counter-clockwise
|
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.rgb = PHOSPHORS[phosphor]
|
||||||
self.style = style
|
self.style = style
|
||||||
self.persist_ms = persist_ms
|
# Clamped, because these arrive as node parameters: focus sets a blur radius, and
|
||||||
self.intensity = intensity
|
# persistence divides.
|
||||||
self.focus = focus
|
self.persist_ms = min(5000.0, max(5.0, float(persist_ms)))
|
||||||
self.jitter = jitter
|
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.rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
self.buf = np.zeros((H, W), np.float32)
|
self.buf = np.zeros((H, W), np.float32)
|
||||||
|
|||||||
@@ -99,3 +99,14 @@ def test_rotation_turns_the_frame_counter_clockwise(rotation):
|
|||||||
def test_bad_rotation_rejected():
|
def test_bad_rotation_rejected():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
FaceRenderer(rotation=45)
|
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')
|
||||||
|
|||||||
Reference in New Issue
Block a user