Clean out the Pi Zero leftovers, rewrite the README, and add the one installer
Wi-Fi-off acceptance passed: with Wi-Fi blocked, live and after a reboot, the Pi came up on the USB link alone, every node ran, and hermes mcp test robot and look() worked. (A temporary guard that would have unblocked Wi-Fi was the safety net and is removed; Wi-Fi is back on.) Delete what the ROS design superseded: the Picamera2 MCP server and its unit, the old installer and requirements, and the duplicate platform check. Move omarchy/ to host/omarchy/. Rewrite README.md for this build: parts, physical steps (including the Audio switch), setup, daily use, the troubleshooting learned the hard way, and the security model. pi/install.sh is now the single installer for a fresh Ubuntu 24.04 image, built from the steps done by hand: ROS, the libcamera fork at a pinned commit, camera_ros, the venv and models, the workspace, USB gadget, audio overlay, firewall (before PipeWire opens a port), PipeWire, mixer and services. Run twice on the live robot, the second run changed nothing and everything stayed healthy. It has not been run on a blank card. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
@@ -8,11 +8,13 @@ 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
|
**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.
|
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.
|
- `pi/` and `ros/` run on the robot; `host/` runs on the owner's machines. `CONTEXT.md` tracks where
|
||||||
|
the work stands; `HANDOFF.md` is the original brief and has drifted (ROS stays on the Pi, and
|
||||||
|
`hermes_mcp` is the only bridge out; see `CONTEXT.md`).
|
||||||
- Package 0a (platform proof) comes before everything; start with `host/check-pi-platform.sh`.
|
- 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.
|
- `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
|
- `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.
|
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.
|
- `README.md` is the guide for this build; keep it a guide for a person, and keep `CONTEXT.md` honest.
|
||||||
- The MCP server uses the MCP Python SDK 2.x (`mcp.server.mcpserver.MCPServer`), not 1.x `FastMCP`.
|
- 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.
|
- Ask before rebooting the Pi, upgrading or holding kernel packages, or opening network ports.
|
||||||
|
|||||||
+29
-27
@@ -223,8 +223,8 @@ returns a JPEG in 0.08 s, a wrong token is refused, the Wi-Fi path is dropped by
|
|||||||
the face went `looking` -> `idle` on connect and `agent gone, sleeping` about 40 s after the client
|
the face went `looking` -> `idle` on connect and `agent gone, sleeping` about 40 s after the client
|
||||||
left. The token is in `/etc/robot-mcp.env` (root:hermes, mode 640), generated by
|
left. The token is in `/etc/robot-mcp.env` (root:hermes, mode 640), generated by
|
||||||
`pi/install-robot-service.sh` and never printed; read it with `sudo cat`. The MCP SDK is in `~/venv`
|
`pi/install-robot-service.sh` and never printed; read it with `sudo cat`. The MCP SDK is in `~/venv`
|
||||||
(`pip install "mcp>=2"`). The old `pi/robot_mcp_server.py`, `pi/robot-mcp.service` and `pi/install.sh`
|
(`pip install "mcp>=2"`). The old Pi Zero files (`pi/robot_mcp_server.py`, `pi/robot-mcp.service`,
|
||||||
are superseded (package 5 rewrites the installer). **Not yet verified:** a real Hermes client, and
|
`pi/requirements.txt`, the old `pi/install.sh`, and `tools/`) were deleted (git has them). **Not yet verified:** a real Hermes client, and
|
||||||
whether Hermes passes MCP image content to the model (HANDOFF package 6).
|
whether Hermes passes MCP image content to the model (HANDOFF package 6).
|
||||||
|
|
||||||
**Hermes on talos is wired to it (2026-10-05, with the owner's authorisation).** Hermes Agent v0.21.5 is
|
**Hermes on talos is wired to it (2026-10-05, with the owner's authorisation).** Hermes Agent v0.21.5 is
|
||||||
@@ -264,34 +264,36 @@ unrelated photo scored 0.16. **Nobody is enrolled yet**; the owner enrols themse
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Finish package 0b: Wi-Fi-off boot test, replug recovery, serial login check, NixOS snippet in
|
1. **Camera calibration** (needs the owner and a flat checkerboard, printed or on a screen, with the
|
||||||
`host/nixos/`. A new Omarchy host with a GPU (runs the local
|
square size measured): collect ~20 views at different poses, solve with OpenCV, write the
|
||||||
model "ornith") arrives this week: it needs only `host/omarchy/robot-usb-link.sh` and its SSH key
|
camera_info YAML so `camera_ros` stops logging the missing-file error. V2's perception needs it.
|
||||||
added to the Pi.
|
2. Waiting on hardware: the servos (`hermes_head`, the pan/tilt URDF, then `head: true`), the speakers
|
||||||
2. `hermes_head` and the servos when they arrive (then `head: true`), echo cancel and the `robot_mic`
|
(playback, echo cancel, the `robot_mic` naming on the host, Hermes talking aloud), the fan (before
|
||||||
naming once speakers arrive, a fan before any enclosure, the Wi-Fi-off boot test, a real Hermes
|
any enclosure), the splitter, the printed enclosure (measure first, see `enclosure/README.md`).
|
||||||
client against `hermes_mcp`, and the rest of `HANDOFF.md`.
|
3. Still open: replug and second-host recovery of the USB link, the serial console login, an
|
||||||
3. Confirm the retuned listening detector live (the flicker fix is unit-tested only); a second person
|
`agent_timeout_s` check against a long idle Hermes session, the retuned listening detector live
|
||||||
in frame for the recognition test (not possible on 2026-10-04).
|
(unit-tested only), a second person in frame, running `pi/install.sh` on a blank card, and the
|
||||||
|
NixOS host config (skipped for now by the owner).
|
||||||
|
4. A new Omarchy host with a GPU (it runs the local `ornith` model) is arriving: it needs only
|
||||||
|
`host/omarchy/robot-usb-link.sh` and its SSH key added to the Pi.
|
||||||
|
|
||||||
## Repository
|
## Repository
|
||||||
|
|
||||||
Branch `master`. `.gitignore` covers `.remember/`, caches, `.venv/`, `*.orig`, `*.rej` and
|
Branch `master`. `.gitignore` covers `.remember/`, caches, `.venv/`, `*.orig`, `*.rej` and
|
||||||
`pi/50-cloud-init.yaml`. `tools/check-pi-platform.sh` duplicates `host/check-pi-platform.sh` and can
|
`pi/50-cloud-init.yaml`. Layout: `ros/` (five packages), `pi/` (everything installed on the Pi),
|
||||||
probably go.
|
`host/` (the owner's machines: `host/omarchy/` has the USB link script, Hermes and PipeWire config and
|
||||||
|
the launcher; `host/check-pi-platform.sh`), `enclosure/`, `docs/face-prototype.html`. `README.md` is
|
||||||
|
rewritten for this build. **`pi/install.sh` is the one installer** (re-runnable: run twice on the live
|
||||||
|
robot, the second run changed nothing, exit 0): it installs ROS, builds the libcamera fork (pinned
|
||||||
|
commit) and `camera_ros`, sets up the venv, models, workspace, USB gadget, audio overlay, firewall,
|
||||||
|
PipeWire, mixer and services. It has not been run on a blank card (no spare card). HANDOFF.md is the
|
||||||
|
original brief and has drifted; where they differ, this file wins.
|
||||||
|
|
||||||
## Security review notes (parked until the USB link is up)
|
## Security notes (all resolved)
|
||||||
|
|
||||||
An automated review of the initial commit flagged three items. All stem from the code predating
|
The automated review of the initial commit flagged three items, all from the Pi Zero design:
|
||||||
the USB-gadget design (HANDOFF.md package 0b); none is exposed until the Pi runs the services.
|
`ROBOT_MCP_BIND` defaulting to `0.0.0.0` (now the USB address only, with a bearer token), PipeWire on
|
||||||
|
TCP 4713 on every interface (now protected by the boot-time firewall, which loads first), and the
|
||||||
- `pi/robot_mcp_server.py`: plain HTTP, `ROBOT_MCP_BIND` defaults to `0.0.0.0`. Plan: default to
|
single-IP any-interface nftables file (rewritten: usb0 from 10.55.0.1 only). Later reviews added:
|
||||||
`10.55.0.2`, start after `usb0`.
|
DDS is blocked on every interface and discovery is local-only; recognised names are not logged; node
|
||||||
- `pi/pipewire/50-network-server.conf`: TCP 4713 on all interfaces, unrestricted. Plan: keep,
|
parameters are clamped. SSH stays open on all interfaces (key-only) by the owner's choice, for V2.
|
||||||
comment that the firewall is the protection.
|
|
||||||
- `pi/nftables-robot.nft`: still the Pi Zero version (single host IP, any interface). Package 0b
|
|
||||||
rewrites it to accept only on the USB interface from `10.55.0.1`.
|
|
||||||
- `pi/install.sh` permissions looked fine (`/etc/robot-mcp.env` is `root:$USER`, 640).
|
|
||||||
|
|
||||||
The owner wants to confirm the real IPs on the link before changing any of this. If the USB
|
|
||||||
link doesn't work, revisit: TLS or a Tailscale-only bind for MCP and audio.
|
|
||||||
|
|||||||
@@ -1,205 +1,175 @@
|
|||||||
# Hermes robot
|
# Hermes robot
|
||||||
|
|
||||||
A small Wi-Fi robot that gives Hermes Agent eyes, ears, a voice and (optionally) a
|
A small desk robot that gives Hermes Agent (Nous Research's open-source agent) eyes, ears,
|
||||||
neck. All thinking happens on your Omarchy machine. The robot only takes pictures,
|
a voice and a face. It does no thinking of its own: Hermes runs on your computer and uses the robot
|
||||||
moves its head, and carries audio.
|
through MCP.
|
||||||
|
|
||||||
|
- **Eyes:** a camera. Hermes calls `look()` and gets a current photo.
|
||||||
|
- **Face:** an oscilloscope-style display that shows what the agent is really doing: asleep (no agent
|
||||||
|
connected), idle, listening, thinking, looking, speaking. The mouth draws the actual voice, and the
|
||||||
|
eyes glance toward whoever the camera sees.
|
||||||
|
- **Ears and voice:** the HAT's two microphones and speakers, shared with your computer as ordinary
|
||||||
|
audio devices.
|
||||||
|
- **Neck:** pan and tilt servos (`turn_head`), once they are fitted.
|
||||||
|
- **One cable:** USB-C carries power and a private network link to your computer. Wi-Fi is a fallback.
|
||||||
|
|
||||||
|
It runs ROS 2 Jazzy on Ubuntu 24.04, so a mobile version can grow out of it. Hermes needs no ROS: it
|
||||||
|
only speaks MCP.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Works and is tested | Waiting on hardware |
|
||||||
|
|---|---|
|
||||||
|
| Face on the display, always on at power-up, with a sleeping pose | Servos: `turn_head`, `head_position`, the pan/tilt URDF |
|
||||||
|
| Camera, face detection, opt-in recognition of people you enrol | Speakers: playback, echo cancel, Hermes talking out loud |
|
||||||
|
| Eyes follow faces; mouth draws your live voice | Fan (the Pi runs at about 62 °C open-air without one) |
|
||||||
|
| MCP bridge: `hermes mcp test robot` passes, a real Hermes turn calls `look()` | USB-C splitter (the Pi is powered from your computer's port for now) |
|
||||||
|
| USB network link, firewall, boots and works with Wi-Fi off | Printed enclosure (`enclosure/`) |
|
||||||
|
|
||||||
|
## How it fits together
|
||||||
|
|
||||||
```
|
```
|
||||||
Omarchy machine Robot (Pi Zero 2 W)
|
Your computer (Omarchy) Raspberry Pi 4 + BrainCraft HAT (Ubuntu 24.04)
|
||||||
─────────────── ───────────────────
|
Hermes Agent ── MCP over HTTP, USB link ────► hermes_mcp (the only way in or out of ROS)
|
||||||
hermes-robot ─► Hermes Agent
|
PipeWire ◄── audio, TCP 4713 ──────────► PipeWire (not ROS)
|
||||||
│ voice mode / wake word
|
│ ROS 2 graph, this machine only
|
||||||
├── default mic = "Robot microphone" ◄─┐
|
camera_ros ► hermes_face_detector ► /vision/faces ─┐
|
||||||
├── default sink = "Robot speaker" ─────┤ PipeWire pulse tunnel, TCP 4713
|
hermes_audio_state ► levels, speaking/listening ───┤
|
||||||
│ └─► pipewire-pulse ─► I²S mic(s) + amp
|
hermes_mcp ► /hermes/state (the agent heartbeat) ──┴► hermes_face ► display
|
||||||
└── MCP tools: look(), turn_head() ──────► robot_mcp_server.py, HTTP 8765
|
|
||||||
├─ Camera Module 3
|
|
||||||
└─ PCA9685 ─► 2 servos (optional)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every node is its own process, started by one launch file (`ros/hermes_bringup`) and restarted if it
|
||||||
|
dies. ROS never leaves the Pi. The firewall blocks it on every interface, so `hermes_mcp` is the one
|
||||||
|
door.
|
||||||
|
|
||||||
## Parts
|
## Parts
|
||||||
|
|
||||||
Prices are rough single-unit figures and will vary by vendor.
|
| Part | Notes |
|
||||||
|
|---|---|
|
||||||
|
| Raspberry Pi 4 | 8 GB here. Ubuntu Server 24.04 arm64. |
|
||||||
|
| Adafruit BrainCraft HAT | 1.54″ ST7789 display, WM8960 audio codec with two mics, speaker and servo ports. |
|
||||||
|
| Arducam UC-346 | OV5647 camera, 15-pin ribbon, fixed focus. |
|
||||||
|
| 2 × MG90S servos | Pan (in the base) on the HAT's GPIO12 port, tilt (camera yoke) on GPIO13. |
|
||||||
|
| 2 × speakers | 8 Ω, up to 1 W, JST PH 2-pin. |
|
||||||
|
| USB-C power/data splitter | Power from a 5.1 V 3 A supply, data only to your computer. A Pi 4 cannot negotiate USB PD. |
|
||||||
|
| Fan | On the HAT's fan header. |
|
||||||
|
| Enclosure | `enclosure/`: printed, with a turntable. |
|
||||||
|
|
||||||
| Part | Notes | ≈ USD |
|
## Physical steps
|
||||||
|---|---|---|
|
|
||||||
| 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.
|
1. The HAT's "3pin Volt" jumper must be on 5 V, which is the factory setting. Check it with a meter.
|
||||||
|
2. Pan servo into the GPIO12 port, tilt servo into GPIO13.
|
||||||
## Wiring
|
3. Camera ribbon: leave a gentle loop, because only the tilt flexes it.
|
||||||
|
4. **Slide the HAT's "Audio" switch (right edge) to ON.** With it off, everything looks configured and
|
||||||
Pin numbers are **physical header pins** (1–40), not GPIO numbers. The microphone(s)
|
every recording is exactly silent. This cost an hour once.
|
||||||
and amplifier share the I²S clock lines; that's expected.
|
5. Power the Pi from the splitter, not straight from a port that cannot give 3 A. Never power it from a
|
||||||
|
laptop or tablet you care about the battery of.
|
||||||
**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
|
## Setting up the Pi
|
||||||
|
|
||||||
1. Flash **Raspberry Pi OS Lite (64-bit)** with Raspberry Pi Imager. In its settings, set the
|
1. **Flash** Ubuntu Server 24.04 for the Pi (arm64, preinstalled image) and check its SHA256.
|
||||||
hostname (e.g. `robot`), your user, your Wi-Fi, and enable SSH.
|
2. **Configure first boot** on the card's `system-boot` partition. In `user-data`, create the user
|
||||||
2. Boot, find the Pi's IP, and give it a **DHCP reservation** on your router.
|
`hermes` with key-only login and the hostname `hermes-bot`. For Wi-Fi, put the access point in
|
||||||
3. Copy the `pi/` folder over and run the installer, passing your Omarchy machine's IP:
|
`network-config` (a template is `pi/50-cloud-init.yaml`; it holds your password, so it is
|
||||||
|
gitignored). cloud-init applies these on the *first* boot only: to change Wi-Fi later, edit
|
||||||
|
`/etc/netplan/50-cloud-init.yaml` on the Pi.
|
||||||
|
3. **Boot, then copy this repo to the Pi and run the installer:**
|
||||||
|
|
||||||
```bash
|
```
|
||||||
scp -r pi robot.local:~/robot-setup
|
rsync -a ./ hermes@<pi>:~/hermes-bot/
|
||||||
ssh robot.local
|
ssh hermes@<pi> 'cd hermes-bot && sudo ./pi/install.sh'
|
||||||
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.
|
It installs ROS, builds the camera driver against the Raspberry Pi libcamera fork, builds the
|
||||||
4. After the reboot, check things on the Pi:
|
workspace, sets up audio, the USB link, the firewall and the services. It can be run again safely.
|
||||||
|
The first run takes a while (the libcamera build alone is about 15 minutes). **Reboot afterwards.**
|
||||||
|
4. Plug the USB-C data cable into your computer.
|
||||||
|
|
||||||
```bash
|
## Setting up your computer (Omarchy)
|
||||||
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`
|
1. **Network link:** `sudo host/omarchy/robot-usb-link.sh` gives your end of the link `10.55.0.1/30`,
|
||||||
in `/etc/robot-mcp.env`, then run `sudo systemctl restart robot-mcp`.
|
matched by the gadget's fixed MAC, so any port and any machine works. The robot is `10.55.0.2`.
|
||||||
|
2. **Hermes:** the token is in `/etc/robot-mcp.env` on the Pi. Put it in `~/.hermes/.env` as
|
||||||
## Setting up Omarchy
|
`ROBOT_MCP_TOKEN=`, and merge `host/omarchy/hermes-config-snippet.yaml` into
|
||||||
|
`~/.hermes/config.yaml`.
|
||||||
1. **Audio tunnel.** Copy `omarchy/50-robot-tunnel.conf` to
|
3. **Audio (optional, needs the speakers):** `host/omarchy/50-robot-tunnel.conf` makes the robot's
|
||||||
`~/.config/pipewire/pipewire.conf.d/`, replace both `ROBOT_IP`s, then:
|
microphone and speaker appear as devices; `host/omarchy/hermes-robot` starts Hermes with them as
|
||||||
|
the defaults.
|
||||||
```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
|
## First test
|
||||||
|
|
||||||
Say or type: *"Look around and tell me what's on my desk."* Hermes should call `look`, then
|
```
|
||||||
describe the scene.
|
ping 10.55.0.2
|
||||||
|
hermes mcp test robot # connects, finds the look tool
|
||||||
|
hermes chat -q "Use the look tool and tell me what you see."
|
||||||
|
```
|
||||||
|
|
||||||
**One thing to confirm early:** that the image actually reaches the model. The server returns a
|
The robot's face wakes when Hermes connects and falls asleep about 40 seconds after it leaves. A
|
||||||
standard MCP image block, and the test client sees it correctly. If Hermes answers as though it
|
look-and-describe turn takes a minute or so on local models: `look()` itself is instant. Hermes does not
|
||||||
saw nothing, check `~/.hermes/logs/agent.log`, and make sure your model supports vision. If
|
give MCP images to the main model: it saves the photo to its image cache and the model calls
|
||||||
Hermes turns out to drop image blocks from MCP results, the fallback is a small change to
|
`vision_analyze` on it.
|
||||||
`look()` to save the JPEG and return a path for Hermes's own vision tool. Tell me if that happens.
|
|
||||||
|
## Day to day
|
||||||
|
|
||||||
|
| State | Face | Comes from |
|
||||||
|
|---|---|---|
|
||||||
|
| sleeping | eyes closed, dim, slowly breathing | no agent connected (no heartbeat) |
|
||||||
|
| idle | blinks, smiles | the agent is connected |
|
||||||
|
| listening | mouth draws your voice, dimmer | the microphone (never wakes a sleeping face) |
|
||||||
|
| thinking | eyes trace looping curves | the agent |
|
||||||
|
| looking | squint, then a flash | `look()` |
|
||||||
|
| speaking | mouth draws the outgoing audio | the speaker |
|
||||||
|
|
||||||
|
- **Logs and control:** `journalctl -u robot -f` (the face logs every state change), `sudo systemctl
|
||||||
|
restart robot`.
|
||||||
|
- **Enrol yourself** so the eyes prefer you (numbers only are stored, never photos, in
|
||||||
|
`~/.local/share/hermes/faces.json`): on the Pi, with the robot running and after `source
|
||||||
|
~/robot-env.sh`, `python3 -m hermes_vision.people enroll YOURNAME`. `list` shows who is enrolled, `forget YOURNAME` removes you.
|
||||||
|
- **Update the code:** `rsync -a --delete --exclude __pycache__ ros/ hermes@<pi>:~/ros_ws/src/`, then
|
||||||
|
`colcon build --symlink-install` in `~/ros_ws`, then `sudo systemctl restart robot`.
|
||||||
|
- **Run a node by hand:** `source ~/robot-env.sh` first: it puts the libcamera fork and the right
|
||||||
|
OpenCV on the path, and keeps ROS local.
|
||||||
|
|
||||||
## Tuning and troubleshooting
|
## Tuning and troubleshooting
|
||||||
|
|
||||||
- **Mic too quiet** (the level bar barely moves): on the Pi, run
|
- **Recordings are exactly silent:** the Audio switch is off (see above).
|
||||||
`wpctl set-volume @DEFAULT_AUDIO_SOURCE@ 2.5`. Alternatively, lower `voice.silence_threshold`
|
- **Microphone too noisy or too quiet:** WirePlumber resets the codec's capture gain to +30 dB. The
|
||||||
in Hermes. A single mic is about 6 dB quieter than a pair, because the unused channel is
|
installer sets PipeWire's source volume to 63% (about +17 dB); change it with `pactl
|
||||||
averaged in.
|
set-source-volume @DEFAULT_SOURCE@ 63%`. Remember that PipeWire's scale is cubic.
|
||||||
- **Hermes interrupts itself:** this is the robot hearing its own speaker. Raise
|
- **The face flickers between listening and idle:** raise `hold_s` in `ros/hermes_audio/hermes_audio/levels.py`.
|
||||||
`voice.barge_in_threshold_multiplier`, point the speaker away from the mics, or enable
|
- **Stock libcamera crashes when capturing:** that is expected on this kernel. The installer builds the
|
||||||
`pi/pipewire/60-echo-cancel.conf.optional` and uncomment the `pulse.device` lines in the
|
Raspberry Pi fork into `/opt/libcamera-rpi` and builds `camera_ros` against it. Anything that uses
|
||||||
tunnel config.
|
the camera must run after `source ~/robot-env.sh`.
|
||||||
- **Audio crackles:** this means weak Wi-Fi. Raise `pulse.latency` to 300–400. The Zero 2 W has
|
- **Display shows noise or a band:** the 240×240 glass sits 80 rows into the controller's memory
|
||||||
only 2.4 GHz Wi-Fi and a small antenna, so keep it within reasonable range of the router.
|
(`y_offset: 80`), and `rotation: 270` is the mounted pose. Both are in `ros/hermes_bringup/config/robot.yaml`.
|
||||||
- **Robot devices vanish after the Pi reboots:** the tunnels reconnect every 5 s by themselves.
|
- **`GPIO busy` or a crash at start from systemd:** the display's GPIO backend needs a writable working
|
||||||
Start `hermes-robot` again once they reappear.
|
directory; the unit sets one.
|
||||||
- **Head turns the wrong way:** set `ROBOT_PAN_INVERT=1` or `ROBOT_TILT_INVERT=1` in
|
- **The USB interface is unmanaged:** netplan must match `usb0` by *name*, not by MAC.
|
||||||
`/etc/robot-mcp.env`, then restart `robot-mcp`.
|
- **`ros2 node list` shows only some nodes:** use `ros2 node list --no-daemon --spin-time 6`.
|
||||||
|
- **The Pi's clock is wrong with Wi-Fi off:** there is no internet to sync from; it stays roughly right
|
||||||
|
but unsynchronised.
|
||||||
|
- **A one-off message to the face is ignored:** ROS drops a message published before the connection
|
||||||
|
matches. Republish it, as `hermes_mcp` does every second.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- The microphone stream (port 4713) and the camera server (port 8765) are open only to your
|
- The robot's ports are open **only over the USB link, only to `10.55.0.1`**: audio (4713), MCP (8765)
|
||||||
Omarchy machine's IP, via nftables on the Pi.
|
and a reserved webhook port (8766). Everything else of those is dropped on Wi-Fi.
|
||||||
- The camera additionally requires the bearer token.
|
- **ROS is blocked on every interface**, including USB. It has no authentication and carries the camera
|
||||||
- Traffic is unencrypted on your LAN. If that matters to you, put both machines on Tailscale and
|
and recognised names.
|
||||||
use the Pi's tailnet address in both configs.
|
- MCP needs a bearer token, kept in `/etc/robot-mcp.env` (root and the service user only).
|
||||||
- If your Omarchy machine's IP changes, re-run `install.sh` with the new one.
|
- **SSH is open on all interfaces** (key-only), by choice, for the mobile version.
|
||||||
|
- Face recognition is opt-in and stores only numbers. Hermes keeps each `look()` photo in its image
|
||||||
|
cache on your computer.
|
||||||
|
- Never power the robot from a device whose battery you care about.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
```
|
| Path | What |
|
||||||
pi/
|
|---|---|
|
||||||
install.sh one-shot Pi setup
|
| `ros/` | The ROS 2 workspace: `hermes_face`, `hermes_vision`, `hermes_audio`, `hermes_mcp`, `hermes_bringup`. |
|
||||||
robot_mcp_server.py MCP server: look, turn_head, head_position
|
| `pi/` | Everything installed on the Pi: `install.sh` and its parts, services, firewall, overlays. |
|
||||||
requirements.txt MCP Python SDK 2.x
|
| `host/` | Your computer's side: the USB link script, Hermes and PipeWire config, a platform check. |
|
||||||
robot-mcp.service systemd unit
|
| `enclosure/` | OpenSCAD model, STLs and the print guide. Built from estimates: measure before printing. |
|
||||||
nftables-robot.nft port restriction
|
| `docs/face-prototype.html` | The browser prototype the face was ported from. |
|
||||||
pipewire/50-network-server.conf exposes audio to the network
|
| `HANDOFF.md` | The original implementation brief. `CONTEXT.md` tracks where things really stand. |
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ SOURCE=robot_mic
|
|||||||
has() { pactl list short "$1" | awk '{print $2}' | grep -qxF "$2"; }
|
has() { pactl list short "$1" | awk '{print $2}' | grep -qxF "$2"; }
|
||||||
if ! has sinks "$SINK" || ! has sources "$SOURCE"; then
|
if ! has sinks "$SINK" || ! has sources "$SOURCE"; then
|
||||||
echo "hermes-robot: the robot's audio devices aren't present." >&2
|
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
|
echo " Is the robot powered and plugged in over USB (ping 10.55.0.2)? Check with: pactl list short sources" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
+152
-83
@@ -1,96 +1,165 @@
|
|||||||
#!/usr/bin/env bash
|
#!/bin/bash
|
||||||
# Robot setup for Raspberry Pi OS Lite (64-bit) on a Pi Zero 2 W.
|
# Set up the robot's Raspberry Pi 4 from a fresh Ubuntu Server 24.04 (arm64) image.
|
||||||
# Run as your normal user (not root) from inside this "pi" folder:
|
|
||||||
#
|
#
|
||||||
# ./install.sh <omarchy-machine-ip> [--servos]
|
# rsync -a ./ hermes@<pi>:~/hermes-bot/
|
||||||
|
# ssh hermes@<pi> 'cd hermes-bot && sudo ./pi/install.sh'
|
||||||
#
|
#
|
||||||
# Safe to re-run. Reboot afterwards so the I2S sound card overlay takes effect.
|
# Re-runnable: every step checks before it changes anything, and the heavy builds skip themselves if
|
||||||
|
# they are already done. Needs the internet (apt, git, pip, model downloads). The first run takes a
|
||||||
|
# while (the libcamera build alone is about 15 minutes). Reboot afterwards: it changes /boot/firmware/
|
||||||
|
# config.txt for the USB gadget and the audio codec.
|
||||||
|
#
|
||||||
|
# The user (default "hermes", set ROBOT_USER) must already exist: create it in cloud-init user-data.
|
||||||
|
# Order matters: the firewall goes in before PipeWire's network server, which listens on every
|
||||||
|
# interface and is protected only by that firewall.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HOST_IP="${1:-}"
|
ROBOT_USER="${ROBOT_USER:-hermes}"
|
||||||
SERVOS=0
|
PI_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
[[ "${2:-}" == "--servos" ]] && SERVOS=1
|
REPO="$(dirname "$PI_DIR")"
|
||||||
|
# Raspberry Pi's fork of libcamera, at a commit known to capture on this kernel. Upstream libcamera,
|
||||||
|
# Ubuntu's 0.2.0 and ROS's 0.7.2 all crash in the Pi image-processing plugin when capturing.
|
||||||
|
LIBCAMERA_REF=6c1dd9d55573010f710c9e190a73e7e76f0d9432
|
||||||
|
CAMERA_ROS_TAG=0.7.0
|
||||||
|
MODEL_BASE=https://github.com/opencv/opencv_zoo/raw/main/models # a plain raw URL returns a Git LFS pointer
|
||||||
|
|
||||||
if [[ -z "$HOST_IP" || ! "$HOST_IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
|
export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a
|
||||||
echo "usage: $0 <omarchy-machine-ipv4> [--servos]" >&2
|
REBOOT=0
|
||||||
exit 1
|
|
||||||
fi
|
[ "$(id -u)" = 0 ] || { echo "run with sudo" >&2; exit 1; }
|
||||||
if [[ $EUID -eq 0 ]]; then
|
HOME_DIR="$(getent passwd "$ROBOT_USER" | cut -d: -f6)"
|
||||||
echo "Run this as your normal user; it calls sudo where needed." >&2
|
[ -n "$HOME_DIR" ] || { echo "no user '$ROBOT_USER': create it in cloud-init user-data first" >&2; exit 1; }
|
||||||
exit 1
|
grep -qs "Ubuntu 24.04" /etc/os-release || echo "warning: this is written for Ubuntu 24.04" >&2
|
||||||
|
|
||||||
|
step() { printf '\n==> %s\n' "$*"; }
|
||||||
|
as_user() { sudo -u "$ROBOT_USER" -H "$@"; }
|
||||||
|
cfg_sum() { sha256sum /boot/firmware/config.txt | cut -d' ' -f1; }
|
||||||
|
USER_UID="$(id -u "$ROBOT_USER")"
|
||||||
|
|
||||||
|
step "Base packages"
|
||||||
|
apt-get update -q
|
||||||
|
apt-get -y -q install git curl rsync software-properties-common openssl rfkill device-tree-compiler i2c-tools \
|
||||||
|
alsa-utils python3-numpy python3-pil python3-spidev python3-lgpio python3-rpi-lgpio python3-pip \
|
||||||
|
python3-venv pipewire pipewire-pulse wireplumber libspa-0.2-modules pulseaudio-utils \
|
||||||
|
meson ninja-build pkg-config python3-yaml python3-ply python3-jinja2 libyaml-dev libgnutls28-dev \
|
||||||
|
libssl-dev libudev-dev libevent-dev libdrm-dev libjpeg-dev libpng-dev libtiff-dev libboost-dev v4l-utils
|
||||||
|
|
||||||
|
step "Groups for hardware access"
|
||||||
|
for g in video audio i2c spi gpio dialout plugdev; do
|
||||||
|
getent group "$g" >/dev/null && usermod -aG "$g" "$ROBOT_USER" || echo " (no group $g on this image)"
|
||||||
|
done
|
||||||
|
|
||||||
|
step "ROS 2 Jazzy"
|
||||||
|
if ! dpkg -s ros-jazzy-ros-base >/dev/null 2>&1; then
|
||||||
|
add-apt-repository -y universe
|
||||||
|
. /etc/os-release
|
||||||
|
V="$(curl -s https://api.github.com/repos/ros-infrastructure/ros-apt-source/releases/latest | grep -F tag_name | awk -F\" '{print $4}')"
|
||||||
|
curl -fL -o /tmp/ros2-apt-source.deb \
|
||||||
|
"https://github.com/ros-infrastructure/ros-apt-source/releases/download/${V}/ros2-apt-source_${V}.${UBUNTU_CODENAME:-$VERSION_CODENAME}_all.deb"
|
||||||
|
dpkg -i /tmp/ros2-apt-source.deb
|
||||||
|
apt-get update -q
|
||||||
fi
|
fi
|
||||||
|
# ros-jazzy-camera-ros is installed for its dependencies; the camera driver actually used is built below
|
||||||
|
apt-get -y -q install ros-jazzy-ros-base ros-dev-tools python3-colcon-common-extensions \
|
||||||
|
ros-jazzy-robot-state-publisher ros-jazzy-xacro ros-jazzy-camera-ros ros-jazzy-vision-msgs
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
step "Raspberry Pi libcamera fork -> /opt/libcamera-rpi"
|
||||||
ME="$(id -un)"
|
if [ -x /opt/libcamera-rpi/bin/cam ]; then
|
||||||
CONFIG_TXT=/boot/firmware/config.txt
|
echo " already built"
|
||||||
[[ -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
|
else
|
||||||
sudo sed -i "s/^ROBOT_SERVOS=.*/ROBOT_SERVOS=$SERVOS/" /etc/robot-mcp.env
|
rm -rf /var/tmp/libcamera-rpi
|
||||||
|
git clone -q --no-checkout https://github.com/raspberrypi/libcamera.git /var/tmp/libcamera-rpi
|
||||||
|
git -C /var/tmp/libcamera-rpi fetch -q --depth 1 origin "$LIBCAMERA_REF"
|
||||||
|
git -C /var/tmp/libcamera-rpi checkout -q FETCH_HEAD
|
||||||
|
(cd /var/tmp/libcamera-rpi \
|
||||||
|
&& meson setup build --buildtype=release -Dprefix=/opt/libcamera-rpi -Dpipelines=rpi/vc4 -Dipas=rpi/vc4 \
|
||||||
|
-Dcam=enabled -Dqcam=disabled -Dgstreamer=disabled -Dpycamera=disabled -Dlc-compliance=disabled \
|
||||||
|
-Dtest=false -Ddocumentation=disabled -Dv4l2=false \
|
||||||
|
&& ninja -C build -j4 && ninja -C build install)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Installing the systemd service"
|
step "Python environment (~/venv: display, OpenCV 4.10 for YuNet, MCP SDK)"
|
||||||
sed "s/__USER__/$ME/g" "$HERE/robot-mcp.service" | sudo tee /etc/systemd/system/robot-mcp.service >/dev/null
|
# OpenCV's DNN needs 4.8+ for the YuNet model and apt has 4.6; numpy stays below 2 because the ROS
|
||||||
getent group i2c >/dev/null || sudo groupadd --system i2c
|
# Python packages are built against 1.x.
|
||||||
sudo systemctl daemon-reload
|
[ -d "$HOME_DIR/venv" ] || as_user python3 -m venv --system-site-packages "$HOME_DIR/venv"
|
||||||
sudo systemctl enable robot-mcp.service
|
as_user "$HOME_DIR/venv/bin/pip" install -q adafruit-circuitpython-rgb-display adafruit-blinka \
|
||||||
|
"opencv-python-headless==4.10.0.84" "numpy<2" "mcp>=2.3,<3"
|
||||||
|
|
||||||
echo "==> Configuring PipeWire to serve audio to $HOST_IP"
|
step "Face models (~/models)"
|
||||||
mkdir -p ~/.config/pipewire/pipewire-pulse.conf.d
|
as_user mkdir -p "$HOME_DIR/models"
|
||||||
cp "$HERE/pipewire/50-network-server.conf" ~/.config/pipewire/pipewire-pulse.conf.d/
|
fetch_model() { # name, path in the zoo
|
||||||
# Run the audio stack at boot without anyone logging in.
|
local dest="$HOME_DIR/models/$1"
|
||||||
sudo loginctl enable-linger "$ME"
|
if [ "$(stat -c %s "$dest" 2>/dev/null || echo 0)" -lt 100000 ]; then
|
||||||
systemctl --user enable pipewire.socket pipewire-pulse.socket wireplumber.service 2>/dev/null || true
|
as_user curl -fsSL -o "$dest" "$MODEL_BASE/$2/$1"
|
||||||
|
fi
|
||||||
|
[ "$(stat -c %s "$dest")" -gt 100000 ] || { echo " $1 looks like an LFS pointer, not the model" >&2; exit 1; }
|
||||||
|
}
|
||||||
|
fetch_model face_detection_yunet_2023mar.onnx face_detection_yunet
|
||||||
|
fetch_model face_recognition_sface_2021dec.onnx face_recognition_sface
|
||||||
|
|
||||||
echo "==> Restricting ports 4713 and 8765 to $HOST_IP"
|
step "camera_ros $CAMERA_ROS_TAG, built against the libcamera fork (~/camera_ws)"
|
||||||
sed "s/__HOST_IP__/$HOST_IP/g" "$HERE/nftables-robot.nft" | sudo tee /etc/nftables-robot.nft >/dev/null
|
if [ -d "$HOME_DIR/camera_ws/install/camera_ros" ]; then
|
||||||
grep -qxF 'include "/etc/nftables-robot.nft"' /etc/nftables.conf \
|
echo " already built"
|
||||||
|| echo 'include "/etc/nftables-robot.nft"' | sudo tee -a /etc/nftables.conf >/dev/null
|
else
|
||||||
sudo systemctl enable nftables.service
|
as_user mkdir -p "$HOME_DIR/camera_ws/src"
|
||||||
sudo systemctl restart nftables.service
|
[ -d "$HOME_DIR/camera_ws/src/camera_ros" ] || as_user git clone -q --depth 1 --branch "$CAMERA_ROS_TAG" \
|
||||||
|
https://github.com/christianrauch/camera_ros.git "$HOME_DIR/camera_ws/src/camera_ros"
|
||||||
|
as_user bash -c "source /opt/ros/jazzy/setup.bash && export PKG_CONFIG_PATH=/opt/libcamera-rpi/lib/aarch64-linux-gnu/pkgconfig \
|
||||||
|
&& cd '$HOME_DIR/camera_ws' && colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=Release"
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
step "Environment script and the robot's ROS workspace (~/ros_ws)"
|
||||||
echo "Done. Reboot now: sudo reboot"
|
install -o "$ROBOT_USER" -g "$ROBOT_USER" -m 644 "$PI_DIR/robot-env.sh" "$HOME_DIR/robot-env.sh"
|
||||||
echo
|
as_user mkdir -p "$HOME_DIR/ros_ws/src"
|
||||||
echo "Your MCP token (put this in ~/.hermes/.env on the Omarchy machine as ROBOT_MCP_TOKEN):"
|
rsync -a --delete --exclude __pycache__ "$REPO/ros/" "$HOME_DIR/ros_ws/src/"
|
||||||
sudo grep '^ROBOT_MCP_TOKEN=' /etc/robot-mcp.env | cut -d= -f2
|
chown -R "$ROBOT_USER:$ROBOT_USER" "$HOME_DIR/ros_ws/src"
|
||||||
|
as_user bash -c "source /opt/ros/jazzy/setup.bash && source '$HOME_DIR/camera_ws/install/setup.bash' \
|
||||||
|
&& cd '$HOME_DIR/ros_ws' && colcon build --symlink-install"
|
||||||
|
|
||||||
|
step "USB gadget (NCM Ethernet + ACM serial) and the audio codec overlay"
|
||||||
|
before="$(cfg_sum)"
|
||||||
|
bash "$PI_DIR/install-usb-gadget.sh"
|
||||||
|
bash "$PI_DIR/install-audio-overlay.sh"
|
||||||
|
[ "$before" = "$(cfg_sum)" ] || REBOOT=1
|
||||||
|
|
||||||
|
step "Firewall (before anything listens on the network)"
|
||||||
|
bash "$PI_DIR/install-firewall.sh"
|
||||||
|
|
||||||
|
step "PipeWire for $ROBOT_USER (audio server, reachable only over the USB link)"
|
||||||
|
loginctl enable-linger "$ROBOT_USER"
|
||||||
|
as_user mkdir -p "$HOME_DIR/.config/pipewire/pipewire-pulse.conf.d"
|
||||||
|
install -o "$ROBOT_USER" -g "$ROBOT_USER" -m 644 "$PI_DIR/pipewire/50-network-server.conf" \
|
||||||
|
"$HOME_DIR/.config/pipewire/pipewire-pulse.conf.d/50-network-server.conf"
|
||||||
|
for _ in $(seq 20); do [ -S "/run/user/$USER_UID/bus" ] && break; sleep 1; done
|
||||||
|
as_user env XDG_RUNTIME_DIR="/run/user/$USER_UID" systemctl --user enable --now \
|
||||||
|
pipewire.socket pipewire-pulse.socket wireplumber.service \
|
||||||
|
|| echo " (the user session is not up yet; PipeWire starts at the next boot)"
|
||||||
|
|
||||||
|
step "Audio codec mixer and microphone gain"
|
||||||
|
if aplay -l 2>/dev/null | grep -q wm8960; then
|
||||||
|
alsactl -f "$PI_DIR/wm8960_asound.state" restore wm8960soundcard && alsactl store wm8960soundcard
|
||||||
|
# WirePlumber resets the codec's capture gain to +30 dB when it takes the device; 63% on PipeWire's
|
||||||
|
# cubic scale is -12 dB from that, about +17 dB.
|
||||||
|
as_user env XDG_RUNTIME_DIR="/run/user/$USER_UID" pactl set-source-volume @DEFAULT_SOURCE@ 63% \
|
||||||
|
|| echo " (could not set the source volume: run pactl set-source-volume @DEFAULT_SOURCE@ 63% later)"
|
||||||
|
else
|
||||||
|
echo " no WM8960 card yet: reboot, then run this script again"
|
||||||
|
REBOOT=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Services: the robot (face, camera, vision, audio, MCP bridge), the CPU governor, trimmed boot"
|
||||||
|
bash "$PI_DIR/install-robot-service.sh"
|
||||||
|
bash "$PI_DIR/trim-boot.sh"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Done.$([ "$REBOOT" = 1 ] && echo " REBOOT NOW: the USB gadget and the audio overlay only take effect then.")
|
||||||
|
|
||||||
|
Next:
|
||||||
|
- Slide the HAT's "Audio" switch (right edge) to ON, or every recording is silent.
|
||||||
|
- Plug the USB-C data cable into your computer, then on it: sudo host/omarchy/robot-usb-link.sh
|
||||||
|
- Hermes needs the MCP token. It is not printed here; read it with:
|
||||||
|
ssh $ROBOT_USER@10.55.0.2 sudo cat /etc/robot-mcp.env
|
||||||
|
then see host/omarchy/hermes-config-snippet.yaml.
|
||||||
|
- Check with: hermes mcp test robot
|
||||||
|
EOF
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
#!/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) ;;
|
|
||||||
*.tar) decomp=(cat) ;;
|
|
||||||
*) echo "Unexpected package layout: $member" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
ar p modules.deb "$member" | "${decomp[@]}" | tar -t > files
|
|
||||||
ar p modules.deb "$member" | "${decomp[@]}" | tar -xO "./boot/config-$abi" > config 2>/dev/null || : > config
|
|
||||||
|
|
||||||
missing=0
|
|
||||||
# check <label> <module file name> <CONFIG_ key>
|
|
||||||
check() {
|
|
||||||
local label="$1" mod="$2" key="$3" state
|
|
||||||
if grep -qE "/${mod}\.ko(\.zst|\.xz|\.gz)?$" files; then
|
|
||||||
state="ok (module)"
|
|
||||||
elif grep -qx "${key}=y" config; then
|
|
||||||
state="ok (built in)"
|
|
||||||
else
|
|
||||||
state="MISSING"; missing=$((missing + 1))
|
|
||||||
fi
|
|
||||||
printf ' %-40s %s\n' "$label" "$state"
|
|
||||||
}
|
|
||||||
# overlay <name>
|
|
||||||
overlay() {
|
|
||||||
if grep -qE "/overlays/$1\.dtbo$" files; then
|
|
||||||
printf ' %-40s ok\n' "$1.dtbo"
|
|
||||||
else
|
|
||||||
printf ' %-40s MISSING\n' "$1.dtbo"; missing=$((missing + 1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Audio (BrainCraft HAT, WM8960 codec at I2C 0x1A)"
|
|
||||||
check "WM8960 codec driver" snd-soc-wm8960 CONFIG_SND_SOC_WM8960
|
|
||||||
check "Simple sound card" snd-soc-simple-card CONFIG_SND_SIMPLE_CARD
|
|
||||||
check "Pi I2S interface" snd-soc-bcm2835-i2s CONFIG_SND_BCM2835_SOC_I2S
|
|
||||||
overlay wm8960-soundcard
|
|
||||||
echo "Camera (Arducam UC-346, OV5647)"
|
|
||||||
check "OV5647 sensor driver" ov5647 CONFIG_VIDEO_OV5647
|
|
||||||
check "Pi camera receiver (Unicam)" bcm2835-unicam CONFIG_VIDEO_BCM2835_UNICAM
|
|
||||||
overlay ov5647
|
|
||||||
echo "Servos (hardware PWM on GPIO12/13)"
|
|
||||||
check "Pi PWM driver" pwm-bcm2835 CONFIG_PWM_BCM2835
|
|
||||||
overlay pwm-2chan
|
|
||||||
echo "Display (ST7789 on SPI)"
|
|
||||||
check "Pi SPI driver" spi-bcm2835 CONFIG_SPI_BCM2835
|
|
||||||
echo "USB link (gadget mode on the USB-C port)"
|
|
||||||
check "DWC2 USB controller" dwc2 CONFIG_USB_DWC2
|
|
||||||
check "Gadget framework (configfs)" libcomposite CONFIG_USB_LIBCOMPOSITE
|
|
||||||
check "NCM Ethernet function" usb_f_ncm CONFIG_USB_F_NCM
|
|
||||||
check "ECM Ethernet function (fallback)" usb_f_ecm CONFIG_USB_F_ECM
|
|
||||||
check "ACM serial function" usb_f_acm CONFIG_USB_F_ACM
|
|
||||||
overlay dwc2
|
|
||||||
|
|
||||||
echo
|
|
||||||
[[ -s config ]] || echo "Note: no kernel config in the package, so built-in drivers would show as MISSING."
|
|
||||||
if (( missing == 0 )); then
|
|
||||||
echo "Everything the robot needs is in Ubuntu's $abi kernel."
|
|
||||||
else
|
|
||||||
echo "$missing item(s) missing from $abi. Ubuntu 24.04 may not suit this robot as-is; see HANDOFF.md package 0a."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
Reference in New Issue
Block a user