Vendors the comprehensive UNO Q skill (SKILL.md + 7 references/*.md) and installs it into EVERY agent's workspace on each board, so agents know this board's specifics (dual-brain arch, Bridge/RPC, pin tables, LED matrix + the ArduinoGraphics-not-installed gotcha) instead of guessing generic Arduino. Why per-agent workspace: ZeroClaw's read_skill returns only SKILL.md; the agent reads references/*.md via the workspace-sandboxed file_read tool, so references are only reachable under ~/.zeroclaw/agents/<alias>/workspace/skills/. A shared/skills bundle surfaces the skill but its references get sandbox-blocked. - push-skill.sh installs a SKILL.md+references skill into every agent workspace (discovers aliases from the board); provision-fleet runs it per board. - config.template risk profile now allows + auto-approves read_skill + file_read so agents load skills without a human approver (webhook path is non-interactive). - Flattened the folded 'description: >-' to single-line (ZeroClaw's frontmatter parser is a flat scanner, not full YAML). Verified on board 65301572 with cloud/Sonnet-5: discovered arduino-uno-q → read_skill(SKILL.md) → file_read references/04-bridge-rpc.md → correct board-specific answer citing the file. Co-Authored-By: Claude Opus 4.8 <[email protected]>
6.7 KiB
UNO Q — The Bridge (Linux ↔ MCU communication)
This is the single most important capability of the board and the thing people get wrong most often. "Connecting the Linux side to the Arduino side" means using the Bridge/RPC over the arduino-router. It never means wiring pins together.
The model: transparent remote procedure calls (RPC)
The two processors exchange data over a dedicated internal serial link. On top of it, a Remote Procedure Call system lets a function on one processor be invoked from the other as if it were a local call. So Python on Linux can call a function that actually runs on the STM32 (and vice-versa), passing arguments and getting results back.
Building blocks:
arduino-router— a background Linux systemd service implementing a star-topology network using MessagePack-RPC. Multiple Linux processes can talk to the MCU and to each other through it simultaneously (multipoint). Clients register the functions they offer; the router keeps a directory and routes calls (service discovery).- The Bridge library — on the MCU side,
Arduino_RouterBridge.h(built onArduino_RPClite) gives you theBridgeobject. On the Linux side, App Lab'sarduino.app_utilsexposes the matchingBridgein Python.
Source repos (for reference): github.com/arduino/arduino-router and github.com/arduino-libraries/Arduino_RouterBridge.
Reserved resources — DO NOT TOUCH
The router owns the physical link. Never open these in your own code:
- Linux:
/dev/ttyHS1 - MCU: the
Serial1hardware port
(Opening them yourself breaks the bridge. Note this is distinct from ordinary Serial debug output, which is fine — see the I/O reference.)
The MCU-side API (Bridge)
Bridge.begin()— initialize the bridge + serial transport (call insetup(); it can return false if it fails).Bridge.provide(name, fn)— expose an MCU function so Linux can call it. Runs in a high-priority RPC thread.Bridge.provide_safe(name, fn)— same, but the function runs inside the mainloop()context, which is safe for using the normal Arduino APIs. Preferprovide_safewhen your handler calls Arduino functions (digitalWrite,analogRead, most libraries).Bridge.call(method, args...)— call a Linux-side function and wait for the result.Bridge.notify(method, args...)— fire-and-forget call (no result awaited).Monitor— a predefined object to stream text from Linux to the MCU (via themon/writeRPC method); legacy,Serialis now preferred for console output.- Concurrency is handled with Zephyr mutexes (
k_mutex); a background thread services incoming updates.
⚠️ Do not call Bridge.call(), Monitor.print(), or Serial.print() inside a provide() handler — the handler runs in the RPC thread and these will misbehave/deadlock. Do the work in the handler, and do any printing/bridge-calling from loop() instead. (This is exactly why provide_safe() exists.)
The Python-side API (App Lab)
from arduino.app_utils import * gives you App and Bridge.
Bridge.call("method_name", args...)— invoke a function the MCU registered withprovide().App.run(user_loop=loop)— App Lab's main loop runner for the Python program.
Canonical example — Linux drives an MCU-owned pin
The MCU owns LED_BUILTIN; Python decides when to toggle it and calls across the bridge.
Linux side — main.py (runs on the MPU):
from arduino.app_utils import *
import time
led_state = False
def loop():
global led_state
time.sleep(1)
led_state = not led_state
Bridge.call("set_led_state", led_state) # invoke the MCU function
App.run(user_loop=loop)
MCU side — sketch.ino (runs on the STM32):
#include "Arduino_RouterBridge.h"
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
Bridge.begin();
Bridge.provide("set_led_state", set_led_state); // expose to Linux
}
void loop() {}
void set_led_state(bool state) {
digitalWrite(LED_BUILTIN, state ? LOW : HIGH); // built-in LED is active-low
}
That is the whole pattern for controlling anything attached to the board from the Linux/Python side: the sketch owns the pin and provide()s a function; Python call()s it. To go the other direction (MCU reads a sensor and pushes to Linux), have Python provide a function and the MCU Bridge.call() it — or have the MCU notify() events.
Managing the router service (troubleshooting the bridge)
systemctl status arduino-router # is it running?
sudo systemctl restart arduino-router # restart it
journalctl -u arduino-router -f # live logs
Enable verbose logging by appending --verbose to the ExecStart= line in
/etc/systemd/system/arduino-router.service:
ExecStart=/usr/bin/arduino-router --unix-port /var/run/arduino-router.sock --serial-port /dev/ttyHS1 --serial-baudrate 115200 --verbose
then:
sudo systemctl daemon-reload
sudo systemctl restart arduino-router
journalctl -u arduino-router -f
Advanced: talk to the router from any language via the Unix socket
The router listens on a Unix domain socket and speaks MessagePack-RPC, so any language (Python, C++, Rust, Go…) can drive the MCU without App Lab:
/var/run/arduino-router.sock
MCU sketch (note provide_safe, since the handler uses digitalWrite):
#include "Arduino_RouterBridge.h"
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
Bridge.begin();
Bridge.provide_safe("set_led_state", set_led_state);
}
void loop() {}
void set_led_state(bool state) { digitalWrite(LED_BUILTIN, state ? LOW : HIGH); }
Raw Python client (no App Lab) — install sudo apt install python3-msgpack:
import socket, msgpack, sys
SOCKET_PATH = "/var/run/arduino-router.sock"
led_state = True
if len(sys.argv) > 1:
led_state = (sys.argv[1] == "1")
# MessagePack-RPC request: [type=0 (request), msgid, method, params]
request = [0, 1, "set_led_state", [led_state]]
packed_req = msgpack.packb(request)
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.connect(SOCKET_PATH)
client.sendall(packed_req)
response = msgpack.unpackb(client.recv(1024))
print(f"Router Response: {response}")
except Exception as e:
print(f"Connection failed: {e}")
python3 msgpack_test.py 1 # LED ON
python3 msgpack_test.py 0 # LED OFF
Mental checklist when wiring the two sides together
- Which side owns the physical resource? → that side
provide()s the function. - Does the handler touch Arduino APIs? → use
provide_safe(). - Never print or
call()from inside aprovide()handler. - Leave
/dev/ttyHS1andSerial1alone. - If nothing gets through, check
systemctl status arduino-routerand the verbose logs first.