Skip to content
 
 

Latest commit

 

History

19 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OOMWOO I/O Firmware

The MCU that keeps an open-source robot vacuum from hurting itself.

STM32G473 · Arduino · FreeRTOS · hard-real-time safety core

CI tests sanitizers on-target License Part of OOMWOO

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.

Status, stated honestly

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.


See it run, in about two minutes

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/program

That 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.

What's in here

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.

The simulator is not a mock

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.

How it's tested

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_core hands the policy an already-decoded frame; test/test_link enters 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.

Architecture — three layers

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.

Safety

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.

How the implemented policy behaves

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 HEARTBEAT and a fresh DRIVE_SETPOINT both 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).

Bring-up milestones

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.

What is not implemented

  • 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.md does 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_TELEMETRY and MCU_DIAGNOSTIC have no payload layout — blocked upstream in the contract, not here.

Why STM32G473VCT6

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.

What the firmware will own

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, and CPU reset.
  • HMI: power/home buttons and LEDs.

CPU ↔ MCU link

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.

Contract gaps found by implementing both ends

Offered as findings, not complaints — each wants a decision from whoever owns the contract:

  1. FAST_TELEMETRY.safety_latched_flags is one byte, but SafetyEvent runs 1..10. CPU_HEARTBEAT_TIMEOUT (9) and ESTOP (10) cannot appear in the periodic snapshot at all. Both ends work around it by tracking those two from SAFETY_EVENT frames instead.
  2. 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.
  3. The reference codec's StreamDecoder loses a frame when a read ends on a lone O — it clears its buffer when it cannot find OW. 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.

Contributing

  • 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's SWDIO/SWCLK header.
  • 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.

References

License

Apache License 2.0. Contributions are made on that basis; safety-critical firmware additionally passes maintainer safety review before merge.

About

Arduino STM32G473VCT6 MCU firmware for OOMWOO I/O board

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages