v2.0.0

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.

RASPBERRY PI 5 — host main.py bootstrap robot.py L298N · HC-SR04 adc.py MCP3008 bit-bang SPI coprocessor.py UART → RP2040 wifi_provision.py nmcli hotspot web.py Flask + SocketIO serves UI UART 115200 8N1 GP14 TX ↔ GP1 RX · GP15 RX ↔ GP2 TX RP2040 — co-processor / MicroPython main.py loop protocol.py JSON parse / dispatch pin_manager.py GP3–GP29 · ADC · PWM

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

lumio/main.py
Entry point. Instantiates all hardware objects, runs WiFi provisioning, then launches the Flask/SocketIO server. Boot sequence is: hardware → WiFi → web.
lumio/config.py
Single source of truth for ALL pin numbers, motion parameters, and network constants. Edit only this file when rewiring.
lumio/robot.py
L298N motor driver and HC-SR04 ultrasonic sensor interface. Provides high-level movement primitives: move_forward, turn, dance, etc. Includes _FakeGPIO stub for dev machines.
lumio/adc.py
Bit-banged SPI driver for the MCP3008. Reads 8 analog channels at 10-bit resolution. Returns raw codes (0–1023) or voltages (0.0–3.3 V).
lumio/coprocessor.py
Non-blocking UART interface to the RP2040. Background thread reads unsolicited events; synchronous commands use request-reply with timeout.
lumio/web.py
Flask + Flask-SocketIO server. REST endpoint for status/GPIO config. WebSocket events for commands and sensor streaming.
lumio/wifi_provision.py
Boot-time WiFi provisioning: checks for saved credentials, starts AP hotspot + captive portal if none found, saves new credentials to /etc/lumio/wifi.conf via nmcli.
lumio-rp2040/main.py
RP2040 MicroPython entry point. UART read loop on GP1/GP2 at 115200 baud. Polls all input pins every 20 ms for edge events.
lumio-rp2040/protocol.py
JSON command dispatcher. Parses incoming frames, calls into PinManager, serialises replies. Also handles async event push to host.
lumio-rp2040/pin_manager.py
Runtime GPIO state machine. Each pin is a 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

Logging setup
RobotController()
MCP3008()
RP2040()
ensure_wifi()
socketio.run()

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.

✓ FIXED — t_start / t_end moved outside while-loop body
Both timestamps are now assigned after their respective loops exit, capturing the true echo edge time rather than the last-iteration timestamp. This eliminates the systematic distance offset that was proportional to loop overhead.
# 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}
⚠ WARNING — BCM GPIO 35 for MISO
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

Web handler thread
send() acquires _lock
writes JSON to UART
waits on _reply_event
_read_loop thread
parses reply frame
sets _reply_event
send() returns

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 ()
✓ FIXED — adc_read now sends "pin" key; gpio_set_mode() added
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
✓ FIXED — Templates use render_template(); no more CWD-relative open()
Both route handlers now use Flask's 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")
✓ FIXED — /api/gpio/config calls gpio_set_mode() correctly
The GPIO config endpoint now calls _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

Check /etc/lumio/wifi.conf
nmcli connect
Success? Return True
↓ fail
Start hotspot (nmcli)
Serve portal on :8080
User submits SSID+pw
Save → nmcli → teardown hotspot

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

raw bytes from main.py
ujson.loads(raw)
frame.get("cmd")
PinManager method
_ok() or _err()

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.

✓ FIX 1 — robot.py · t_start / t_end moved outside while-loop body
File: 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.
✓ FIX 2 — coprocessor.py · adc_read key corrected from "ch" to "pin"
File: 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.
✓ FIX 3 — coprocessor.py · gpio_set_mode() added
File: lumio/lumio/coprocessor.py

The 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.
✓ FIX 4 — web.py · render_template() + gpio_set_mode() in config endpoint
File: lumio/lumio/web.py

Two 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).
⚠ OPEN — config.py · BCM 35 for ADC_MISO — verify against your board
File: lumio/lumio/config.py, line 28

BCM 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.
✓ Everything else is solid
Graceful degradation in all hardware paths (GPIO unavailable, UART not open, RP2040 offline). Thread safety on motor commands. Protocol symmetry between firmware and host for 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.