System Architecture
This document describes the system architecture of “Palmimo DevKit,” a desktop-sized hexapod robot.
This document covers the
palmimo_sdkrobot.pyfacade +engine.pycomputation layer +ServoDriverabstraction: the current structure and the design intent behind it (why this layering).
Overall System Architecture
Section titled “Overall System Architecture”The overview is shown below.
Power is external and compute is a Raspberry Pi: the DevKit carries a Raspberry Pi 5 (16 GB) as its control host, the servos run from a 5 V DC adapter, and the Pi from its own USB PD supply — there is no onboard battery. During development a PC can also act as the host (see below).
Execution Model
Section titled “Execution Model”The execution host is assumed to be a Raspberry Pi by default. The control loop (gait generation → servo writes, 60 Hz) runs entirely on the Pi, which connects directly to the OpenRB-150 (USB–DYNAMIXEL bridge) over USB serial. Because servo round trips never cross a network, the loop is unaffected by network latency or jitter. During development, a PC connects over SSH etc., but is not involved in control.
| Layer | Component | Role |
|---|---|---|
| Development machine | PC (SSH, etc.) | Editing, launching, log viewing. Not involved in control |
| Execution host | Raspberry Pi (default assumption) | Runs palmimo_sdk (Palmimo + MotionEngine + ServoDriver) and user code |
| Interface board | OpenRB-150 | USB-connected to the Pi. Ships pre-flashed as a USB–DYNAMIXEL bridge, relaying servo commands onto the bus |
| Actuators | 21× serial-bus smart servos | 18 leg + 3 neck. Protocol 2.0 / baud 1,000,000 |
Software Architecture
Section titled “Software Architecture”Data Flow
Section titled “Data Flow”- User calls
robot.forward()-> sets Motion enum - Each
robot.step()call -> MotionEngine computes servo positions - Returns
Dict[str, int](servo_name -> tick_value) - ServoDriver sends positions to the bus (compute-only when no driver is injected)
- Repeat at target fps (default 60)
Key Design Principles
Section titled “Key Design Principles”- Computation != Communication: engine.py never touches serial ports
- Neutral Contract: Every motion starts and ends at neutral stance — raw
servo neutral (2048) on every joint except the neck pitch, which settles at
the engine’s rest-trimmed center (see
neck_rest_pitch_degtuning in api-reference.md) - Frame-based: Each step() produces one frame of servo positions
- Deterministic: Same motion + same phase = same output (testable without hardware)
- Lean core:
pyserialis the SDK’s only base dependency; every heavier import stays behind an extra, soimport palmimo_sdkneeds nothing beyondpyserial. TTS synthesis is pluggable (TtsEngine, defaultPiperEngineoverpiper-plus) and lives inpalmimo_sdk.io.ttsbehind thespeechextra;palmimo_sdk.io.speaker.Speakeritself is engine-agnostic and only owns queueing/playback. Shared mic capture, GTCRN denoise, and DTLN-AEC echo cancellation (palmimo_sdk.audio) live behind thevoiceextra. Any further voice-command processing (VAD / KWS / ASR) is the caller’s responsibility, built on top of these — see theexamples/agents/wakewordapp for a concrete instance palmimo_sdk.ioowns I/O resources: every hardware boundary is consolidated underio/— servos areServoDriver/DynamixelDriver, camera capture isHeadCamera, and mic/speaker/display areMicrophone/MicStream/Speaker/FaceDisplay. Upper layers only consume these; they never open a backend themselves.palmimo_sdk.iois an internal layer — consumers import from thepalmimo_sdkroot (direct reach-in is blocked by ruff TID251). Heavy backends (dynamixel_sdk/cv2/piper/pyserial/sounddevice) are imported lazily, so importing the module itself stays hardware-independent. This avoids duplicating the same capture logic across consumers (vision processing lives in the app, capture lives in the SDK).MicStreamis the resource that shares one physical mic across several consumers (an always-on VAD, on-demand ASR capture, a denoiser, …): it opens a singlesounddevice.InputStreamon a background thread and fans each chunk out to everysubscribe()d consumer. That aMicrophoneand aMicStreampoint at the same physical mic is expressed only by the convention of giving both the samedevice_keystring (there is no mechanism that matches device identities)palmimo_sdk.audiois a pure processing layer: whereioowns hardware and returns raw bytes/arrays,audioonly transforms sample streams and holds no I/O — the same separation asengine.pyholding no servo I/O. GTCRN noise removal (SpeechDenoiser/StreamingDenoiser, indenoise.py) and DTLN-AEC acoustic echo cancellation (DtlnResidual/GatedDtlnCanceller, indtln.py) both live here, lazily importingsherpa_onnx/ai-edge-litert/numpy.AudioProcessor(a WAV-bytes-in/mono-WAV-bytes-outProtocol) is the DI seamMicStream/Microphoneroute captured audio through, and any number of processors can be cascaded — only the first may require more than one input channel (itscapture_channelsconvention).DenoiserwrapsStreamingDenoiserbehind that contract;EchoCanceller(inaec.py) wrapsGatedDtlnCancellerthe same way, consuming a multi-channel WAV (microphone + loudspeaker reference) and producing mono.MicStreamdefaults toprocessors=[EchoCanceller()]([]for raw audio)- The facade bundles peripherals: peripherals are owned symmetrically with the
driver by the facade, as
Palmimo(display=..., speaker=..., camera=..., mic=...);connect()/disconnect()open and close them together with the servo bus —connect()also auto-wakes a connected driver, anddisconnect()parks it (neutral return + neck soft-release) before closing. Every peripheral is optional — with no device present,set_expression()/say()become no-ops returningNone(upper layers never need to know whether the physical layer exists). If one peripheral fails to open after another has already opened, the ones already open are closed before the error is re-raised, so a partial failure never leaves a port open. Unlikeset_expression()/say(),HeadCamera/Microphonedon’t get a read method added on the facade — since a single physical device can have multiple readers (for a camera: VLM observe / wave watch), consumers read/record directly viarobot.camera/robot.mic(the same shape asrobot.driver.write_positions)
Peripheral Ownership
Section titled “Peripheral Ownership”Palmimo optionally owns the driver plus FaceDisplay / Speaker / HeadCamera /
Microphone, and connect() / disconnect() open and close them together. Data flows
in two directions — servo / face / speech send commands, camera / mic ingest data —
but direction does not decide ownership. Palmimo owns all of them regardless.
Microphone is used by apps whose interaction completes within a single recording.
The SDK also ships MicStream (shared streaming capture) and palmimo_sdk.audio
(GTCRN noise removal, DTLN-AEC echo cancellation), so raw chunk capture, denoising,
and echo cancellation can all ride on the SDK. VAD / KWS / ASR themselves remain the caller’s
responsibility — the SDK stops short of them. An app that runs its own streaming
VAD + ASR stack and doesn’t use the SDK’s mic simply keeps mic=None — the facade
“can own it, but doesn’t force it.” This is exactly the same treatment as every
other peripheral; mic is not a special case.
Package Structure
Section titled “Package Structure”A uv-managed workspace. palmimo_sdk is the core; every other package consumes it
through the Palmimo facade. The dependency only ever runs that way — the core
never imports a package built on top of it:
pyproject.toml # uv workspace rootpackages/ palmimo_sdk/ # Core SDK — the single user-facing window palmimo_sdk/ __init__.py # Re-exports Palmimo / Motion / RoutineStep / MotionEngine / ServoDriver / DynamixelDriver / kinematics / palmimo_motor_ids / SUPPORTED_MOTOR_MODELS robot.py # Palmimo facade — public API + connection lifecycle engine.py # MotionEngine — pure gait/IK computation, no I/O kinematics.py # Shared IK/FK (leg_ik / servo ticks / body-frame foot position) io/__init__.py # Re-exports ServoDriver / DynamixelDriver / HeadCamera / Microphone / MicStream / Speaker / FaceDisplay / find_servo_port io/base.py # ServoDriver ABC (I/O boundary) + ServoTelemetry io/dynamixel.py # DynamixelDriver — concrete ServoDriver over the Dynamixel bus (+ find_servo_port auto-detection) io/camera.py # HeadCamera — head-camera capture resource (full-FOV MJPG -> downscale -> rotate) io/microphone.py # Microphone — USB mic capture (arecord/rec → WAV bytes) io/mic_stream.py # MicStream — shared streaming mic capture (background thread, fan-out to subscribers) io/_mic_registry.py # Process-local device_key registry coordinating Microphone <-> MicStream ownership io/speaker.py # Speaker — non-blocking TTS orchestration (queue/worker/barge-in), delegates synthesis to a TtsEngine io/tts/base.py # TtsEngine ABC / TtsVoice protocol — the synthesis-engine boundary io/tts/piper.py # PiperEngine — the default TtsEngine, over piper-plus (MIT) io/display.py # FaceDisplay — host-side client for the RP2350 face display (USB-CDC) audio/denoise.py # SpeechDenoiser / StreamingDenoiser — GTCRN (sherpa-onnx) noise removal, pure sample transforms audio/dtln.py # DtlnResidual / GatedDtlnCanceller — DTLN-AEC (ai-edge-litert) echo cancellation, pure sample transforms audio/aec.py # EchoCanceller — AudioProcessor implementation wrapping GatedDtlnCanceller audio/processor.py # AudioProcessor protocol (WAV in/out, DI/cascade) + Denoiser/ClipDenoiser + int16_to_wav/wav_to_int16/wav_to_int16_multi tests/ # engine / robot / driver / kinematics testsexamples/ agents/ wakeword/ # Wake-word voice agent example (Silero VAD + Whisper STT + LLM tool-calling) companion/ # Always-on companion agent example (idle loop + speech/vision-driven responses, LLM tool-calling) openclaw/ # Connection kit for driving Palmimo from OpenClaw (self-hosted AI assistant) over the MCP serverscripts/ # Supported user diagnostics (diagnose_servos: scan / power / errors / joints / recover / oscillate)Dependencies
Section titled “Dependencies”pyserial>=3.5— the SDK’s only base dependency (serial port communication)- Everything else lives behind an extra in
packages/palmimo_sdk/pyproject.toml:hardware(dynamixel-sdk, the servo communication protocol, viaDynamixelBus),face(pyserial, forFaceDisplay),speech(piper-plusTTS),voice(mic streaming plus denoise/AEC —sounddevice,numpy,sherpa-onnx,ai-edge-litert),agent(pydantic, the LLM tool-calling layer),vision(opencv-python, forHeadCamera), andmcp(mcp,mcp-types,starlette,uvicorn, plusagent) tqdm>=4.66.0— a dependency of this repository’s ownscripts/, not of the SDK package
Design Intent (Why This Layering)
Section titled “Design Intent (Why This Layering)”- Hardware-independent SDK entry point — reaching for a motor library directly
couples callers to internal implementation.
palmimo_sdkis the single window, so users only need to know thePalmimofacade - Separating motion computation from I/O — gait generation, IK, and other motion
computation is confined to
MotionEngine, which only passes computed results (servo positions) to the Driver. The computation layer is testable without hardware - Driver abstraction — I/O is encapsulated behind the
ServoDriverABC, in preparation for future backends - Lean core — heavy optional dependencies (voice / tts) stay behind extras.
import palmimo_sdkcosts one base dependency (pyserial), and app-level processing built on top of them lives in theexamples/apps (this is also a premise of distributing it as an OSS library)
As shown in the software architecture diagram above, the entry-point class Palmimo
delegates motion computation to MotionEngine, and I/O is encapsulated behind ServoDriver.
The backbone policy is to keep computation (gait / IK) and communication (serial) in
separate layers.
robot.py(Palmimo) = state and orchestration — holds the user-facing API and the connection lifecycle (with-block control):connect()opens resources and, with a connected driver, auto-runs thewake()glide (limp -> neutral) unless built withauto_wake=False;disconnect()parks the robot first (return-to-neutral, then a neck soft-release ramp) before closing.__exit__delegates todisconnect()— a clean exit parks fully, but after an exception only the neck soft-release runs (motion commands are skipped so they can’t mask the error / act on a possibly-bad state). Computation is delegated toMotionEngine, I/O toServoDriverengine.py(MotionEngine) = pure computation — computes and returns servo positions from commands/frames only. Holds no state and does no I/O (testable without hardware)io/(ServoDriver+ concrete drivers) = the I/O boundary — streams computed servo positions to the backend.dynamixel_sdk(DynamixelBus) is imported lazily via an optional extra (palmimo-sdk[hardware]), so the core stays hardware-independent
Separating motion selection (forward(), etc.) from execution (run(seconds=) /
run(steps=)) lets you specify “how many seconds / how many steps” without counting
control frames yourself, and the control rate (fps) is centralized in Palmimo.
run() is also the one place a caller running on another thread can interrupt the
robot: Palmimo.cancel() increments an internal counter under a small lock and is
documented as the single facade method safe to call cross-thread (it touches no other
state, so it cannot race the motion loop’s own reads/writes). Each paced public method
(run() / perform_dance() / play_realtime()) snapshots that counter the instant it
is entered, and _pace() — the shared pacing loop all three use — compares the live
counter against that snapshot once per frame, raising MotionCancelled there if it
increased, unwinding the blocking call within roughly one frame period instead of only
once it finishes on its own. perform_dance()’s own non-paced end-hold and glide-home
sections poll the same counter on a short interval, and guarantee stop() (IDLE) runs
before a cancellation propagates out of it. Taking the snapshot at entry (rather than
clearing a threading.Event at the pacing loop’s own start, the earlier design) means a
stray cancel() delivered while idle never carries over into the next call, without the
clear()/set() race a clearable Event has at that same entry point.
A caller that DISPATCHES a paced call onto another thread (rather than calling it
inline) has one more window to close: a cancel() landing after dispatch but before
the worker thread has actually reached its paced method’s own entry would otherwise be
absorbed by that method’s entry snapshot. Palmimo._arm_cancel_scope() /
_disarm_cancel_scope() close this structurally — arm records the live counter as the
armed baseline right before dispatch, and whichever paced method the worker reaches
first consumes it (via _take_cancel_snapshot()) as ITS OWN entry snapshot instead of a
fresh read of the counter, so a cancel() delivered anywhere from arming onward is
guaranteed to raise MotionCancelled. AgentToolSet.call() arms before handing a
tool’s execute() to its worker thread and disarms in a finally once that worker
finishes, closing the window end-to-end for anything going through the agent toolset.
Safety Design
Section titled “Safety Design”This section makes the Hardware Safety section of AGENTS.md concrete from the SDK design perspective. The guiding principle is to fail safe even if control stops or an SSH session drops.
- Safe servo range — strictly enforce 200–3900 ticks, staying away from the
mechanical limits (0 / 4095) (clamped in
io/base.py’sServoDriver.write_positions, so every backend gets it, not just the Dynamixel one) - Neutral stance at startup — connection and script startup always begin smoothly from the neutral stance; abrupt jumps are prohibited
- Servo telemetry —
ServoDriver.read_telemetry()reads present current, input voltage and temperature in a single bus sweep (io/base.py, implemented over a span read inio/_dynamixel_bus.py). One sweep rather than one read per signal is what lets a guard sample inside the control loop’s frame budget. A motor that does not answer is reported as unread, never as a zero — an invented reading would be taken for evidence of health. Readings are surfaced; nothing acts on them - Stopping a motion, not cutting torque — when a guard does act, the intended
response is to end the motion (
motion → IDLE) and leave torque on. Cutting torque on a servo makes that joint go limp, and a limp joint on a standing robot drops it — worse than the fault being guarded against. The servo’s own firmware remains the breaker of last resort - Temperature is reported, not acted on — a servo that is already hot does not cool at the speed a guard could react, so ending the motion buys little; and over an eight-hour exhibition day these servos measured around 50 °C, well inside their rating. The reading is surfaced and the decision to stop is left to the operator, who can see the room and the schedule; nothing acts on it
- Losing control is a different case from guarding against a reading — when the bus disconnects, status packets go wrong, or the loop stalls, there is no motion left to end, and the fallback is torque-off (limp) or a safe hold depending on whether the robot is bearing load. That is not in tension with leaving torque on above: a guard acts while control still works, this is what happens when it does not
I/O Runtime Environment Requirements
Section titled “I/O Runtime Environment Requirements”palmimo_sdk’s core is hardware-independent, but the execution host that drives real
I/O is assumed to be a Raspberry Pi by default, with the following prerequisites.
- OS — 64-bit Linux (aarch64, e.g. Raspberry Pi OS).
uvand thedynamixel_sdkdependency must resolve - Runtime —
uv(software setup must succeed) - Serial permissions — the execution user belongs to the
dialoutgroup - Device naming — a stable OpenRB-150 port name via
udev(fixed so the number doesn’t change across USB unplug/replug)

