The MCU that keeps an open-source robot vacuum from hurting itself.
STM32G473 · Arduino · FreeRTOS · hard-real-time safety core
Firmware for the OOMWOO I/O board's STM32G473VCT6. OOMWOO splits compute across two processors: a CPU module runs ROS 2, SLAM, Nav2 and behaviour; this MCU owns motors, encoders, sensors, charging, and hard safety.
Its defining constraint — safety must never depend on Linux or ROS 2. And, as the architecture below explains, it must never depend on the friendly Arduino layer either.
What works: the CPU↔MCU wire format, a streaming parser that survives damaged and fragmented input, the MCU-side safety policy, and a simulator that runs all of it over a pseudo-terminal so the ROS 2 side can be developed today. 54 host tests, green in CI under ASan/UBSan.
What does not: anything on silicon. No measured reaction times, no interrupt priorities, no proof that a hung task cannot defeat a cutoff. That is milestone 6 and it needs a Nucleo. Motors, sensors, and charging are unstarted and open — see the milestone table if you want one.
This is the foundation under the safety system, not the safety system.
No board, no hardware, no sudo. Needs Linux (the simulator uses openpty) — WSL is fine.
git clone https://github.com/Creative-Dhanush/oomwoo-io-firmware
cd oomwoo-io-firmware
pip install -U platformio
pio test -e native # 54 tests under AddressSanitizer + UndefinedBehaviorSanitizer
pio run -e native_sim # build the MCU simulator
python3 sim/test_ow_sim_mcu.py --binary .pio/build/native_sim/programThat last command is the interesting one. It starts the simulator, talks to it over a real pseudo-terminal, and decodes every byte it emits using the upstream reference codec — then re-encodes each frame and requires it to reproduce the original bytes exactly. It also drives real scenarios: a cliff latching, a bumper releasing, garbage on the line, and the CPU going silent.
Want the whole robot? Pair it with the ROS 2 bridge:
git clone https://github.com/Creative-Dhanush/oomwoo-mcu-bridge
cd oomwoo-mcu-bridge && colcon build && source install/setup.bash
ros2 launch oomwoo_mcu_bridge demo.launch.py \
sim_binary:="$PWD/../oomwoo-io-firmware/.pio/build/native_sim/program"Now /cmd_vel moves the wheels and /odom advances. Type cliff left on into the simulator's
stdin and the wheels stop — and /cmd_vel cannot bring them back, because a latched fault
releases only on an explicit CLEAR_LATCHED_FAULT. Then kill the bridge: the heartbeat stops and
the MCU stops the motors on its own, with nothing in ROS 2 asking it to.
That is the whole design in one demo.
| Module | What it does |
|---|---|
src/ow_frame.{h,cpp} |
The wire format: CRC-16/CCITT-FALSE, little-endian helpers, encode/decode for all 12 message types. Built byte-at-a-time and never memcpy'd through a struct, because C++ padding does not match Python's struct.pack layout. |
src/ow_stream_decoder.{h,cpp} |
Fixed-buffer streaming decoder. A real UART hands back half a frame, or three frames and a fragment, or noise — this buffers across reads and resyncs one byte at a time after damage. |
src/ow_safety_core.{h,cpp} |
The safety state machine: heartbeat timeout, setpoint expiry, bumper, cliff, wheel-drop, overcurrent, e-stop, latched faults. Design doc. |
src/ow_link.{h,cpp} |
Ties those three into a working MCU — bytes in, policy, framed bytes out. Owns no transport, which is why the same code unit-tests with zero I/O, runs on a pty, and cross-builds for the target. |
src/ow_telemetry.{h,cpp} |
Periodic FAST_TELEMETRY at 50 Hz. |
src/no_heap.h |
Deletes operator new, so an accidental allocation is a compile error, not a field failure. |
sim/ow_sim_mcu_main.cpp |
The MCU on a laptop, over a pseudo-terminal, with stdin fault injection. |
tools/, test_vectors/, fuzz/ |
The upstream reference codec (vendored unmodified), golden frame vectors, and the differential fuzz harness. |
ow_sim_mcu is a drop-in replacement for the newline-delimited-JSON stub in
oomwoo-install — same --link, --period,
--battery-mv — except it speaks the real binary contract. More importantly, it is not a model of
this firmware, it runs this firmware: the same ow_frame / ow_stream_decoder / ow_safety_core
translation units that cross-compile for the G473. There is one implementation, so the simulator and
the target cannot drift apart.
The CPU side that talks to it is oomwoo-mcu-bridge.
Every push, in CI, in about two minutes:
| Check | Result |
|---|---|
pio test -e native under ASan + UBSan |
54 tests — 10 frame codec, 9 stream decoder, 15 safety core, 20 link loopback |
| Golden frame vectors regenerate byte-identical from the reference | pass |
| Differential fuzz: our decoder vs the Python reference | 2000 cases, 0 mismatches |
| Simulator over a pty, every byte decoded and re-encoded by the reference | 6 cases, byte-exact |
pio run -e nucleo_g474re cross-build |
pass |
Three deliberate choices about how it is tested, because a test suite can be green and worthless:
- The oracle is somebody else's code. Frames are checked against the vendored upstream reference codec, not against our own encoder. Agreeing with yourself proves nothing.
- The loopback suite goes through the wire.
test/test_safety_corehands the policy an already-decoded frame;test/test_linkenters and leaves as bytes, so a fault in framing, CRC, sequencing, or packing fails a test. - Nothing sleeps. Every entry point takes the current time as an argument and never reads a clock, so a 150 ms timeout is asserted at exactly 149 ms and 151 ms, deterministically.
pio test -e native builds with sanitizers and therefore needs libasan/libubsan — it does not
run on a Windows/MinGW box. Use WSL or Linux; CI is the authoritative check either way.
The usual "friendly Arduino or deterministic real-time safety" trade-off is a false choice. You get both by layering, so the layer a contributor touches is not the layer that keeps the robot safe:
| Layer | What lives there | Owned by | Determinism |
|---|---|---|---|
| 3 — Arduino (STM32duino) | New behaviours, features, peripheral bring-up. The contributor-friendly surface. | community | best-effort |
| 2 — FreeRTOS tasks (static allocation) | CPU-serial comms, control loop, telemetry, charging supervisor, safety supervisor. Watchdog-fed. | maintainer + community | soft real-time |
| 1 — Real-time core (HAL + timer ISRs) | Motor commutation/PWM, encoder capture, hard-safety cutoffs, CPU watchdog. | maintainer, safety-reviewed | hard real-time |
The rule that makes this safe: contributor code lives at layer 3; the safety core is HAL/ISR and structurally isolated from it. An infinite loop in someone's Arduino feature cannot defeat a cliff stop, an overcurrent cutoff, or the CPU watchdog, because those live in interrupts and a hardware watchdog the upper layers cannot starve.
Layers 1 and 2 are not built yet. What exists today is the portable policy and protocol core that layer 1 will call into — deliberately written with no heap, no exceptions, and no Arduino headers so it compiles unchanged in both places.
Hard safety runs on the MCU, independent of both Linux/ROS 2 and the Arduino layer. The design intent (CE-oriented):
- Bumper / cliff / wheel-drop → immediate motor stop, at ISR level.
- Per-motor overcurrent limiting — a stuck brush or jammed wheel is cut before thermal or mechanical damage.
- CPU watchdog — if the CPU's health packets stop, the MCU stops the motors and can assert the CPU-reset line.
- IWDG, static allocation, and measured, documented worst-case reaction times.
The policy is deliberately dumb, and that is the point — it only ever withholds motion, never commands it. Navigation, mapping, and recovery belong to the CPU.
- Latched faults do not self-clear. Cliff and e-stop release only on an explicit
CLEAR_LATCHED_FAULT. A robot that resumes driving the instant a cliff sensor flickers is the failure mode this rules out. Wheel-drop is the one exception — it clears on contact returning, matching the contract's wording. - Overcurrent stops only the affected motor, reporting which one in
SAFETY_EVENT.detail. - Commands expire. A drive setpoint carries a duration and stops when it lapses, so a dead CPU cannot leave the robot driving.
- Actuators start disabled after boot or reconnect until a fresh
HEARTBEATand a freshDRIVE_SETPOINTboth arrive — so a slow-booting CPU cannot produce motion.
Safety-critical changes require maintainer review before merge, plus a short hazard note (over-current, thermal, short, mechanical pinch).
Phased, each testable before the board exists — start on a Nucleo-G474. Milestones 3–7 are open and unclaimed. Say so in Discussions and take one.
| # | Milestone | Done when | State |
|---|---|---|---|
| 1 | Blink + SWD + serial echo on a G473 dev board | it blinks, SWD attaches, serial echoes | open |
| 2 | CPU serial link — framing + health/watchdog handshake | loopback and echo tests green | done on host |
| 3 | One drive motor, closed loop — H-bridge PWM + encoder capture + velocity PID | holds a commanded velocity; the pattern every other motor follows | open |
| 4 | All actuators — fan (BLDC + FG), brushes, LiDAR spin, pump, mop motors/servos | each exercised on the bench with current sense, documented | open |
| 5 | All sensors — cliff/dock/side IR (ADC), bumpers, wheel-drop, IMU (SPI) | each read and sanity-checked on the bench | open |
| 6 | Safety layer — ISR-level cutoffs, overcurrent limiting, IWDG, CPU watchdog/reset | measured worst-case reaction time per cutoff, plus a hazard note | open — the policy exists, the ISR and timing work does not |
| 7 | Charging supervisor — power-path control, 0.5C cap, input DPM | 0.5C held; graceful degradation on a weak charger | open |
| 8 | Integration — end to end against the CPU or a simulated MCU | the ROS 2 hardware bridge drives the robot | simulated-MCU half done |
Project-level acceptance criteria: a deterministic real-time core with measured cutoff latencies; safety proven layer-independent by hanging an Arduino task on purpose and showing the cliff-stop still fires; the serial contract loopback- and integration-tested; every actuator and sensor exercised reproducibly; charging per spec; and maintainer safety review with a hazard note.
- No STM32 HAL or board bring-up. Everything here is host-testable logic.
- No motor PWM, motor-power-enable GPIO, charging, or IWDG. The safety core emits intents
(stop,
SAFETY_EVENT,NACK); a caller wires them to hardware. No motor load should be connected to anything in this repo. - No measured reaction times. Simulator timing is laptop timing under a preemptive scheduler.
- Encoder and wheel-base constants are placeholders, labelled in the source, because
SPEC.mddoes not fix gearbox ratio or encoder resolution yet. Odometry distance from the simulator is meaningless; direction, sign, and stopping when safety says stop are not. POWER_TELEMETRYandMCU_DIAGNOSTIChave no payload layout — blocked upstream in the contract, not here.
The board drives ~10 actuators and reads a dozen-plus analog channels, so the MCU needs headroom the earlier STM32G0 (Cortex-M0+, no FPU) didn't comfortably have:
- Cortex-M4F @ 170 MHz with an FPU — real control math (PID, filters, odometry) in hardware float, with cycles to spare for FreeRTOS and the Arduino layer.
- Many timers including HRTIM — enough PWM channels for every motor, plus high-resolution timing for clean BLDC/fan drive.
- 5× 12-bit ADCs — per-motor current sense,
VBat, source current, 4× cliff IR, 2× dock IR, 2× side IR. Five ADCs let safety-critical currents be sampled fast and independently. - CORDIC + FMAC accelerators, 512 KB flash / 128 KB RAM, LQFP100 — hand-solderable and JLCPCB-friendly.
STM32duino supports the G4 family and FreeRTOS is available via STM32FreeRTOS, so "Arduino API +
FreeRTOS" is a real, supported combination on this part.
Source of truth is the board SPEC.md — treat it as authoritative over this summary, including its open TODOs:
- Actuators: 2× drive wheels (H-bridge + hall encoders), suction fan (BLDC, PWM + FG), main brush, side brush(es), 2D-LiDAR spin motor, water pump, mop motors, and the mop-lift / mop-arm / side-brush-arm servos — with current sense where the board provides it.
- Sensors: 4× cliff IR, 2× dock IR, 2× side-proximity IR (+ IR-LED PWM), 2× bumper switches,
2× wheel-drop switches, wheel encoders, IMU (SPI + interrupts + FSYNC), and the current-sense /
VBat/ source-current analog channels. - Power & charging: power-path charger supervision (0.5C cap, input DPM, USB-C PD and dock
input, graceful "insufficient charger"), plus
motors power enable,vacuum power,CPU power on/off, andCPU reset. - HMI: power/home buttons and LEDs.
A custom serial protocol over UART — deliberately not micro-ROS, so the safety core carries no heavyweight third-party dependency and no risk of an upstream library update slipping a bug into safety-critical firmware. Framing, command set, telemetry, and the health/watchdog handshake are defined in the io-board-interface RFC.
offset size field
0 2 magic, ASCII "OW"
2 1 protocol version (1)
3 1 flags
4 2 sequence
6 2 message type
8 2 payload length
10 N payload
10+N 2 CRC-16/CCITT-FALSE over header + payload
The MCU accepts bounded, expiring commands, publishes telemetry, and enforces the handshake that backs the CPU watchdog.
Offered as findings, not complaints — each wants a decision from whoever owns the contract:
FAST_TELEMETRY.safety_latched_flagsis one byte, butSafetyEventruns 1..10.CPU_HEARTBEAT_TIMEOUT(9) andESTOP(10) cannot appear in the periodic snapshot at all. Both ends work around it by tracking those two fromSAFETY_EVENTframes instead.- A CPU that attaches late misses
MCU_HELLO, and the catalog has no CPU→MCU "identify" request to ask again with. On a pty the frame waits in the buffer; on a real UART it is gone. - The reference codec's
StreamDecoderloses a frame when a read ends on a loneO— it clears its buffer when it cannot findOW. Both ends here diverge to keep that byte, with a test that fails if upstream ever fixes it, so the workaround gets deleted rather than outliving its reason.
On the heartbeat timeout: the contract still marks it draft at "100, 150, or 250 ms?", so it is a
Config field defaulting to 150 ms rather than a constant. The ~5 minute figure from
discussion #49 is a different timer — CPU
boot time — and boot is handled structurally by the fresh-input gate described under
Safety.
- Toolchain: STM32duino via PlatformIO or
the Arduino IDE; G473 through the generic-G4 definition (bring up on a Nucleo-G474 first).
FreeRTOS via
STM32FreeRTOS. SWD (ST-Link) on the board'sSWDIO/SWCLKheader. - Environments:
native(host tests),native_fuzz(differential fuzz),native_sim(the pty simulator),nucleo_g474re(on-target). - The core stays portable. No heap, no exceptions, no Arduino headers in
ow_*— it has to build as hosted C++17 and on-target from the same source. - Take a milestone. 3–7 are open. Post in Discussions or Discord first so nobody duplicates work.
- AI-assisted commits carry
Co-Authored-By:trailers, per project policy.
- Board: oomwoo-io-board · SPEC.md
- CPU↔MCU contract: io-board-interface RFC
- CPU-side bridge: oomwoo-mcu-bridge
- System architecture: ARCHITECTURE.md §5.4
- This contribution upstream: contributions/mcu-io-firmware/Creative-Dhanush
- Project Discussions · Discord
Apache License 2.0. Contributions are made on that basis; safety-critical firmware additionally passes maintainer safety review before merge.