Lumio V2 Codebase Documentation
Full technical reference for the Lumio V2 educational classroom robot — covering both the Raspberry Pi 5 software stack and the RP2040 co-processor firmware. Includes a bugs report with fixes.
To View the Hardware docs and BOM click HERE
System Overview
Lumio V2 is a two-processor robot: a Raspberry Pi 5 runs the high-level software (web UI, WiFi, motor control, ADC), and an RP2040 acts as a GPIO co-processor, expanding the available I/O with runtime-configurable pins, PWM, and ADC channels over a UART JSON protocol.
Think of it like a city with two governments: the Pi is the mayor (policy, communication, web services) and the RP2040 is the public works department (raw GPIO muscle, sensor polling, hardware-level pin management). They communicate via a simple JSON postal system over UART.
Architecture
The system has four hardware subsystems managed by separate software layers. This separation — like your RS-485 master/slave pattern from the workshop server project — means each layer can be tested or replaced independently.
| Subsystem | Hardware | Pi GPIO | Software Owner |
|---|---|---|---|
| Motor drive | L298N H-bridge | BCM 17,27,22 (A) / 18,23,12 (B) | robot.py |
| Obstacle sensing | 2× HC-SR04 ultrasonic | BCM 5,6 (front) / 13,26 (back) | robot.py |
| Analog sensing | MCP3008 10-bit ADC | BCM 16,20,35,21 (bit-bang SPI) | adc.py |
| GPIO expansion | RP2040 co-processor | BCM 14,15 (UART ttyAMA0) | coprocessor.py |
The web layer (web.py) is a thin adapter: it
translates WebSocket events from the browser into method
calls on RobotController, MCP3008,
and RP2040 objects — similar to how your
MQTT/Redis gateway translated upstream messages into RS-485
bus commands.
Module Map
move_forward, turn,
dance, etc. Includes
_FakeGPIO stub for dev machines.
/etc/lumio/wifi.conf via nmcli.
PinManager, serialises
replies. Also handles async event push to host.
PinState object with mode, current
machine object, and edge-detection state. Supports:
input, output, ADC, PWM, unused.
Pi — main.py · Entry Point
Boots the platform in a strict sequence. All hardware objects are instantiated here and passed into the web server factory function — this is dependency injection, preventing any module from needing global singletons.
Boot Sequence
Key Behaviour
If rp.ping() fails at boot, the system logs a
warning and continues — the RP2040 is treated as optional
hardware. Same for WiFi: missing network does not prevent
the web server from starting.
Environment Variables
| Variable | Default | Effect |
|---|---|---|
LUMIO_PORT |
80 | HTTP port for the web server |
LUMIO_LOG |
/var/log/lumio/lumio.log | Log file path |
Pi — config.py · Pin Definitions
The single source of truth for all hardware constants. Changing a wire? Edit only this file. Every other module imports from here — making config like the one schematic everyone reads from, rather than hardcoded magic numbers scattered across files.
Pin Groups
| Group | Constants | Notes |
|---|---|---|
| Motor A (left) | MOTOR_A_IN1=17, IN2=27, ENA=22 |
ENA is hardware PWM on Pi 5 |
| Motor B (right) | MOTOR_B_IN3=18, IN4=23, ENB=12 |
ENB is hardware PWM on Pi 5 |
| Front US | FRONT_TRIG=5, FRONT_ECHO=6 |
|
| Back US | BACK_TRIG=13, BACK_ECHO=26 |
Changed from old REAR_ECHO=19 |
| MCP3008 | CS=16, MOSI=20, MISO=35, CLK=21 |
⚠ BCM 35 — see Issues |
| RP2040 UART | PORT=/dev/ttyAMA0, BAUD=115200 |
BCM 14/15 |
| I2C | SCL=3, SDA=2, BUS=1 |
Motion Constants
| Constant | Value | Used in |
|---|---|---|
LINEAR_SPEED_MPS |
0.7 m/s | move_forward/backward timing |
ROTATION_SPEED_DPS |
270 °/s | turn() timing |
OBSTACLE_THRESHOLD_CM |
20 cm | Emergency stop trigger |
PWM_FREQUENCY_HZ |
200 Hz | Motor enable PWM frequency |
Pi — robot.py · Motor + Sensor Control
Controls the L298N H-bridge and both HC-SR04 ultrasonic
sensors. The class is thread-safe: all movement methods
acquire self._lock, so the web server can't
issue two simultaneous movement commands and cause GPIO
conflicts.
Public API
| Method | Signature | Returns | Notes |
|---|---|---|---|
move_forward |
(distance_m=1.0, duty=50) |
bool | False = blocked by obstacle |
move_backward |
(distance_m=1.0, duty=50) |
bool | False = blocked by obstacle |
turn |
(angle_deg, duty=50) |
bool | +ve = CW (right), –ve = CCW (left) |
stop |
() |
None | Immediate, no nudge |
emergency_stop |
(obstacle_front=True) |
None | Stops + nudges away from obstacle |
dance |
() |
bool | Pre-programmed routine |
say_hi |
() |
bool | Pre-programmed wave |
cleanup |
() |
None | Call on shutdown |
Distance Measurement
Uses the standard HC-SR04 timing method: 10 µs TRIG pulse, then measure ECHO pulse width. Distance = pulse_time × 17150 cm/s (speed of sound / 2, rounded). Timeout of 100 ms per phase prevents lockup on missing sensors.
# Current (fixed) code while GPIO.input(echo_pin) == 0: if time.monotonic() > deadline: return 999.0 t_start = time.monotonic() # set after loop exits — correct while GPIO.input(echo_pin) == 1: if time.monotonic() > deadline: return 999.0 t_end = time.monotonic() # set after loop exits — correct
Simulation Mode
If RPi.GPIO is not installed (dev machine, CI),
the module falls back to _FakeGPIO — a minimal
stub that silently no-ops all GPIO calls. This lets you run
and test the web server without physical hardware.
Pi — adc.py · MCP3008 ADC Driver
A pure bit-bang SPI driver — no kernel SPI device, no
spidev used here. Think of it like I2C
bit-banging you've seen in firmware, but for SPI: manually
toggling CS, CLK, MOSI, and reading MISO pin-by-pin.
The MCP3008 protocol: send 5 bits (start + SGL/DIFF + D2..D0
channel select), then clock in 11 bits back (1 null + 10
data). The driver masks the result to 10 bits with
& 0x3FF.
Public API
| Method | Args | Returns |
|---|---|---|
read_raw |
channel: int (0-7) |
int 0–1023 |
read_voltage |
channel: int (0-7) |
float 0.0–3.3 V |
read_all_raw |
— | dict {0..7: int} |
read_all_voltage |
— | dict {0..7: float} |
ADC_MISO = 35 is set in
config.py. BCM 35 exists on the Compute
Module and Pi 5 40-pin header as physical pin 19 — but
verify against your specific board variant. On a
standard Pi 5, physical pin 19 is BCM 10 (SPI0 MISO).
Confirm with gpio readall before first
power-on to avoid a short.
Pi — coprocessor.py · RP2040 UART Interface
A non-blocking serial interface. A background daemon thread
reads continuously from /dev/ttyAMA0 and splits
on newlines — identical in concept to the background reader
thread in your RS-485 MQTT gateway. Synchronous commands use
a threading.Event as a one-shot signal: send,
wait for event with timeout, return the last-received reply.
Threading Model
Public API
| Method | Signature | Returns |
|---|---|---|
ping |
() |
bool |
gpio_set_mode |
(pin: int, mode: str, label: str = "")
|
bool |
gpio_write |
(pin: int, value: int) |
bool |
gpio_read |
(pin: int) |
int | None |
adc_read |
(pin: int) — GP26–GP29 only, must be in
adc mode
|
float | None |
on_event |
(callback: Callable[[dict], None]) |
— |
send |
(payload: dict) |
dict | None |
close |
() |
— |
adc_read(pin) now sends
{"cmd": "adc_read", "pin": pin}, matching
what the firmware's
frame.get("pin") expects. The parameter was
also renamed from channel to
pin to reflect that GP26–GP29 are passed as
pin numbers, not channel indices.gpio_set_mode(pin, mode, label="") was
added as a new convenience wrapper — it was missing
entirely but required by web.py's GPIO
config endpoint:
def gpio_set_mode(self, pin: int, mode: str, label: str = "") -> bool: reply = self.send({"cmd": "gpio_set_mode", "pin": pin, "mode": mode, "label": label}) return bool(reply and reply.get("ok")) def adc_read(self, pin: int) -> float | None: reply = self.send({"cmd": "adc_read", "pin": pin}) # "pin", not "ch" if reply and reply.get("ok"): return reply.get("value") return None
Pi — web.py · Flask / SocketIO Server
A thin integration layer. The Flask app is created at module
level (not inside the factory) which means the global
app and socketio objects exist
before create_app() is called — this is a
standard Flask pattern, but means you can't run two server
instances in one process.
HTTP Endpoints
| Method | Path | Description |
|---|---|---|
| GET | / |
Robot control dashboard (control.html) |
| GET | /gpio |
RP2040 GPIO programming panel (gpio.html) |
| GET | /api/status |
JSON snapshot: front/back distance, ADC voltages, RP2040 online flag |
| POST | /api/gpio/config |
Set RP2040 pin modes; body:
{"configs": [{"pin": N, "mode":
"input|output|adc|pwm|unused", "label":
"..."}]}
|
WebSocket Events
| Direction | Event | Payload |
|---|---|---|
| Client → Server | command |
{"type":
"forward|backward|left|right|dance|hi|stop",
"value": float, "duty": int}
|
| Client → Server | get_sensor |
{"type": "front|back|adc|rp_gpio", "ch": int,
"pin": int}
|
| Server → Client | command_result |
{"ok": bool, "cmd": str} |
| Server → Client | sensor_data |
{"type": str, "cm": float, "raw": int,
"voltage": float}
|
| Server → Client | rp2040_event |
Forwarded RP2040 async event frame |
render_template(), which resolves paths
against the
template_folder="../templates" already
configured on the app object. The service starts
correctly regardless of working directory, and the file
handle is never left open.
# Current (fixed) from flask import Flask, render_template, jsonify, request @app.route("/") def index(): return render_template("control.html") @app.route("/gpio") def gpio_page(): return render_template("gpio.html")
_rp.gpio_set_mode(pin, mode, label),
sending the correct gpio_set_mode command
to the firmware. The bogus value integer
and the misrouted gpio_write call are
removed. All five modes (input,
output, adc, pwm,
unused) are now properly forwarded.
# Current (fixed) for cfg in configs: pin = cfg.get("pin") mode = cfg.get("mode", "input") # input|output|adc|pwm|unused label = cfg.get("label", "") ok = _rp.gpio_set_mode(pin, mode, label) results.append({"pin": pin, "ok": ok})
Pi — wifi_provision.py · WiFi Setup
Handles the classic IoT "first-boot" problem: the device doesn't know your WiFi password yet. The approach is an AP hotspot + captive portal — like how home routers let you set them up via a connected laptop.
Boot Decision Tree
Defaults
| Constant | Value |
|---|---|
| Hotspot SSID | Lumio-Setup |
| Hotspot Password | lumio1234 |
| Portal IP | 192.168.50.1 |
| Portal Port | 8080 |
| Credentials file | /etc/lumio/wifi.conf |
RP2040 — main.py · Firmware Entry Point
MicroPython entry point. Creates a UART on GP1(TX)/GP2(RX)
at 115200 baud, instantiates PinManager and
Protocol, then runs a tight loop: read UART →
process complete JSON lines → poll inputs for edge events
every 20 ms.
# Main loop structure while True: chunk = uart.read(256) if chunk: buf += chunk while b"\n" in buf: line, buf = buf.split(b"\n", 1) proto.handle(line) # dispatch command if ticks_diff(now, last_poll) >= POLL_MS: pm.poll_events(proto.push_event) # check input edges sleep_ms(1)
The 1 ms sleep keeps the loop from burning CPU while still being responsive. POLL_MS=20 ms means input edge events are detected with up to 20 ms latency — fine for sensors, too slow for high-speed encoders.
RP2040 — protocol.py · Command Dispatcher
A pure dispatcher: it owns the UART send path and maps
command strings to PinManager method calls. No
state of its own beyond references to UART and PinManager.
Think of it like a router in your MQTT/RS-485 bridge — it
reads a message, figures out which handler owns it, and
forwards it.
Command Handling Flow
RP2040 — pin_manager.py · GPIO State Machine
Each pin is a PinState object that holds its
current mode, the live MicroPython machine object
(Pin, ADC, or PWM),
and last-known value for edge detection. The
release() method calls
PWM.deinit() before reconfiguring — like
properly closing a file before reopening it in a different
mode.
Pin Availability
| Pins | Status | Capabilities |
|---|---|---|
| GP1, GP2 | RESERVED | UART TX/RX — never reassignable |
| GP3–GP25 | FREE | Digital input, output, PWM |
| GP26–GP29 | FREE | Digital + ADC (channels 0–3) |
PinState API
| Method | Valid Modes | Returns |
|---|---|---|
configure(mode, label) |
any | bool |
digital_read() |
input, output | int | None |
digital_write(value) |
output | bool |
adc_read_u16() |
adc | int 0–65535 | None |
adc_read_voltage() |
adc | float 0.0–3.3 V | None |
pwm_set(freq, duty) |
pwm | bool |
check_edge() |
input | (changed: bool, value: int|None) |
Note: inputs are initialised with
Pin.PULL_DOWN. If your sensor requires pull-up,
you'll need to pass Pin.PULL_UP in
configure() or use an external resistor.
RP2040 ↔ Pi — Full Protocol Reference
All messages are newline-terminated JSON (\n).
The Pi is always master: it sends commands, the RP2040
replies synchronously. The RP2040 can additionally push
unsolicited event frames when an input pin
changes state.
Commands (Pi → RP2040)
// Health check {"cmd": "ping"} // Configure a pin's mode and optional label {"cmd": "gpio_set_mode", "pin": 5, "mode": "input", "label": "IR sensor"} {"cmd": "gpio_set_mode", "pin": 6, "mode": "output", "label": "LED"} {"cmd": "gpio_set_mode", "pin": 26, "mode": "adc"} {"cmd": "gpio_set_mode", "pin": 7, "mode": "pwm"} {"cmd": "gpio_set_mode", "pin": 8, "mode": "unused"} // Digital I/O {"cmd": "gpio_read", "pin": 5} {"cmd": "gpio_write", "pin": 6, "value": 1} // ADC (pin must be GP26–GP29 in adc mode) — key is "pin", not "ch"! {"cmd": "adc_read", "pin": 26} // PWM (duty: 0–65535 u16 resolution) {"cmd": "pwm_set", "pin": 7, "freq": 50, "duty": 4915} // Introspection {"cmd": "list_pins"} // all assignable pins {"cmd": "get_all"} // only non-unused pins
Replies (RP2040 → Pi)
{"ok": true}
{"ok": true, "value": 1} // gpio_read
{"ok": true, "value": 1.6523} // adc_read (voltage, 3.3V ref)
{"ok": true, "pins": [{"pin": 5, "mode": "input", "label": "IR", "value": 0}, ...]}
{"ok": false, "error": "pin 26 not in adc mode"}
Async Events (RP2040 → Pi, unsolicited)
{"event": "change", "pin": 5, "value": 1} // rising edge
{"event": "change", "pin": 5, "value": 0} // falling edge
✓ Resolved Issues — Changelog
Four bugs were identified and fixed across three files. The warning about BCM 35 remains open — it requires physical board verification before first power-on.
robot.py · t_start / t_end moved
outside while-loop body
lumio/lumio/robot.py,
measure_distance_cm()Both timestamps were reassigned on every loop iteration, so they captured the last-before-exit iteration time rather than the true echo edge. Moving them to after each loop exits gives accurate pulse timing.
coprocessor.py · adc_read key
corrected from "ch" to "pin"
lumio/lumio/coprocessor.py,
adc_read()Key mismatch between Pi and firmware: Pi was sending
"ch", firmware reads
frame.get("pin"). All co-processor ADC
reads were silently returning error responses. Fixed to
"pin"; parameter renamed from
channel to pin accordingly.
coprocessor.py · gpio_set_mode()
added
lumio/lumio/coprocessor.pyThe method was missing entirely but required by
web.py's GPIO config endpoint. Added as a
new convenience wrapper that sends
{"cmd": "gpio_set_mode", "pin": ..., "mode": ...,
"label": ...}
— a command the firmware already fully supported.
web.py · render_template() +
gpio_set_mode() in config endpoint
lumio/lumio/web.pyTwo fixes in one file: (1) Both route handlers replaced
render_template_string(open(...)) with
render_template(), which resolves paths
correctly via Flask's template folder regardless of CWD.
(2) The /api/gpio/config endpoint replaced
the misrouted gpio_write call with
gpio_set_mode(pin, mode, label).
config.py · BCM 35 for ADC_MISO —
verify against your board
lumio/lumio/config.py, line 28BCM 35 is not a standard 40-pin header GPIO on all Pi 5 variants. Confirm the actual BCM number for physical pin 19 on your specific board with
gpio readall before powering on. A wrong
pin could cause a GPIO conflict or silently broken SPI
reads. No code change made — requires hardware
confirmation first.
gpio_read,
gpio_write, ping,
list_pins, get_all, and async
events. PWM duty clamping. Pin reservation enforcement.
Edge detection state machine. Systemd service file is
correct. Dependency management via uv/pyproject.toml
is clean.