This repository has been archived on 2026-05-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
2026-04-09 16:56:05 +05:30

1756 lines
78 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lumio V2 Codebase Documentation</title>
<style>
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Syne:wght@400;600;700;800&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,300&display=swap");
:root {
--bg: #0a0c10;
--bg2: #10131a;
--bg3: #161b26;
--border: #1e2635;
--accent: #3b82f6;
--accent2: #06b6d4;
--accent3: #f59e0b;
--danger: #ef4444;
--warn: #f97316;
--ok: #22c55e;
--text: #e2e8f0;
--text2: #94a3b8;
--text3: #64748b;
--code-bg: #0d1117;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background: var(--bg);
color: var(--text);
font-family: "DM Sans", sans-serif;
font-size: 15px;
line-height: 1.7;
}
/* ── Sidebar ── */
nav {
position: fixed;
top: 0;
left: 0;
width: 260px;
height: 100vh;
background: var(--bg2);
border-right: 1px solid var(--border);
overflow-y: auto;
z-index: 100;
padding: 24px 0;
}
.nav-logo {
padding: 0 20px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.nav-logo .title {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 1.1em;
color: var(--accent);
letter-spacing: -0.02em;
}
.nav-logo .sub {
font-size: 0.75em;
color: var(--text3);
margin-top: 2px;
}
nav a {
display: block;
padding: 6px 20px;
color: var(--text2);
text-decoration: none;
font-size: 0.82em;
font-family: "DM Sans", sans-serif;
transition:
color 0.15s,
background 0.15s;
border-radius: 0;
}
nav a:hover {
color: var(--text);
background: var(--bg3);
}
nav .nav-section {
padding: 14px 20px 4px;
font-size: 0.68em;
font-family: "Syne", sans-serif;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text3);
}
nav a.active {
color: var(--accent);
border-left: 2px solid var(--accent);
padding-left: 18px;
}
/* ── Main content ── */
main {
margin-left: 260px;
padding: 48px 56px;
max-width: 1000px;
}
/* ── Sections ── */
section {
margin-bottom: 56px;
}
h1 {
font-family: "Syne", sans-serif;
font-weight: 800;
font-size: 2.2em;
color: var(--text);
letter-spacing: -0.03em;
margin-bottom: 8px;
}
h1 span {
color: var(--accent);
}
h2 {
font-family: "Syne", sans-serif;
font-weight: 700;
font-size: 1.35em;
color: var(--text);
letter-spacing: -0.02em;
margin-bottom: 16px;
margin-top: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
h3 {
font-family: "Syne", sans-serif;
font-weight: 600;
font-size: 1em;
color: var(--accent2);
margin: 20px 0 8px;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.78em;
}
p {
color: var(--text2);
margin-bottom: 12px;
}
a {
color: var(--accent);
}
/* ── Hero banner ── */
.hero {
background: linear-gradient(
135deg,
#0f1928 0%,
#0a1520 50%,
#06101c 100%
);
border: 1px solid var(--border);
border-radius: 12px;
padding: 40px;
margin-bottom: 48px;
position: relative;
overflow: hidden;
}
.hero::before {
content: "";
position: absolute;
top: -60px;
right: -60px;
width: 220px;
height: 220px;
background: radial-gradient(
circle,
rgba(59, 130, 246, 0.12) 0%,
transparent 70%
);
pointer-events: none;
}
.hero .version {
display: inline-block;
background: rgba(59, 130, 246, 0.15);
color: var(--accent);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 20px;
padding: 3px 12px;
font-size: 0.75em;
font-family: "JetBrains Mono", monospace;
margin-bottom: 14px;
}
.hero p {
color: var(--text2);
max-width: 600px;
}
/* ── Code blocks ── */
pre {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
overflow-x: auto;
margin: 12px 0 20px;
font-family: "JetBrains Mono", monospace;
font-size: 0.82em;
line-height: 1.6;
}
code {
font-family: "JetBrains Mono", monospace;
font-size: 0.85em;
background: var(--bg3);
padding: 2px 6px;
border-radius: 4px;
color: var(--accent2);
}
pre code {
background: none;
padding: 0;
font-size: inherit;
color: #c9d1d9;
}
.kw {
color: #ff7b72;
}
.str {
color: #a5d6ff;
}
.cm {
color: #6e7681;
font-style: italic;
}
.fn {
color: #d2a8ff;
}
.num {
color: #f2cc60;
}
/* ── Bug / warning cards ── */
.bug-card {
background: rgba(239, 68, 68, 0.07);
border: 1px solid rgba(239, 68, 68, 0.25);
border-left: 3px solid var(--danger);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.warn-card {
background: rgba(249, 115, 22, 0.07);
border: 1px solid rgba(249, 115, 22, 0.25);
border-left: 3px solid var(--warn);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.ok-card {
background: rgba(34, 197, 94, 0.07);
border: 1px solid rgba(34, 197, 94, 0.2);
border-left: 3px solid var(--ok);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.info-card {
background: rgba(59, 130, 246, 0.07);
border: 1px solid rgba(59, 130, 246, 0.2);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.card-title {
font-family: "Syne", sans-serif;
font-weight: 700;
font-size: 0.88em;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.bug-card .card-title {
color: var(--danger);
}
.warn-card .card-title {
color: var(--warn);
}
.ok-card .card-title {
color: var(--ok);
}
.info-card .card-title {
color: var(--accent);
}
.card-body {
color: var(--text2);
font-size: 0.88em;
line-height: 1.6;
}
.card-body code {
font-size: 0.9em;
}
/* ── Tables ── */
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0 20px;
font-size: 0.85em;
}
th {
text-align: left;
padding: 10px 14px;
background: var(--bg3);
color: var(--text2);
font-family: "Syne", sans-serif;
font-weight: 600;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border);
}
td {
padding: 9px 14px;
border-bottom: 1px solid var(--border);
color: var(--text2);
vertical-align: top;
}
td code {
font-size: 0.88em;
}
tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
/* ── Architecture diagram ── */
.arch {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 28px;
margin: 16px 0 24px;
font-family: "JetBrains Mono", monospace;
font-size: 0.8em;
line-height: 1.9;
color: var(--text2);
}
.arch .highlight {
color: var(--accent2);
}
.arch .dim {
color: var(--text3);
}
.arch .arrow {
color: var(--accent3);
}
/* ── Architecture SVG ── */
.arch-svg-wrap {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
margin: 16px 0 24px;
overflow-x: auto;
}
/* ── Module cards ── */
.module-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin: 16px 0 24px;
}
.module-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px;
transition: border-color 0.2s;
}
.module-card:hover {
border-color: var(--accent);
}
.module-card .filename {
font-family: "JetBrains Mono", monospace;
font-size: 0.82em;
color: var(--accent2);
margin-bottom: 6px;
}
.module-card .desc {
font-size: 0.84em;
color: var(--text2);
line-height: 1.5;
}
/* ── Protocol flow ── */
.flow {
display: flex;
align-items: center;
gap: 0;
margin: 12px 0;
flex-wrap: wrap;
}
.flow-node {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 14px;
font-family: "JetBrains Mono", monospace;
font-size: 0.78em;
color: var(--text);
}
.flow-arrow {
padding: 0 8px;
color: var(--accent3);
font-size: 0.9em;
}
/* ── Badge ── */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.72em;
font-family: "Syne", sans-serif;
font-weight: 700;
margin-left: 6px;
vertical-align: middle;
}
.badge-danger {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.badge-warn {
background: rgba(249, 115, 22, 0.15);
color: var(--warn);
border: 1px solid rgba(249, 115, 22, 0.3);
}
.badge-ok {
background: rgba(34, 197, 94, 0.15);
color: var(--ok);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.badge-info {
background: rgba(59, 130, 246, 0.15);
color: var(--accent);
border: 1px solid rgba(59, 130, 246, 0.3);
}
/* ── Divider ── */
hr {
border: none;
border-top: 1px solid var(--border);
margin: 32px 0;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
/* ── Responsive ── */
@media (max-width: 900px) {
nav {
display: none;
}
main {
margin-left: 0;
padding: 32px 24px;
}
.module-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<nav>
<div class="nav-logo">
<div class="title">🤖 Lumio V2</div>
<div class="sub">Codebase Documentation</div>
</div>
<div class="nav-section">Overview</div>
<a href="#overview">System Overview</a>
<a href="#architecture">Architecture</a>
<a href="#modules">Module Map</a>
<div class="nav-section">Pi-Side Software</div>
<a href="#main">main.py</a>
<a href="#config">config.py</a>
<a href="#robot">robot.py</a>
<a href="#adc">adc.py</a>
<a href="#coprocessor">coprocessor.py</a>
<a href="#web">web.py</a>
<a href="#wifi">wifi_provision.py</a>
<div class="nav-section">RP2040 Firmware</div>
<a href="#fw-main">main.py (RP2040)</a>
<a href="#fw-protocol">protocol.py</a>
<a href="#fw-pinmgr">pin_manager.py</a>
<a href="#fw-protocol-ref">Protocol Reference</a>
<div class="nav-section">Changelog</div>
<a href="#bugs">✓ Resolved Issues</a>
</nav>
<main>
<!-- HERO -->
<div class="hero">
<div class="version">v2.0.0</div>
<h1><span>Lumio V2</span> Codebase Documentation</h1>
<p>
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.
</p>
</div>
<!-- BOM -->
<section id="bom">
<h4>
To View the Hardware docs and BOM click
<a href="./Hardware/bom/ibom.html">HERE</a>
</h4>
</section>
<!-- OVERVIEW -->
<section id="overview">
<h2>System Overview</h2>
<p>
Lumio V2 is a two-processor robot: a
<strong>Raspberry Pi 5</strong> runs the high-level software
(web UI, WiFi, motor control, ADC), and an
<strong>RP2040</strong> acts as a GPIO co-processor,
expanding the available I/O with runtime-configurable pins,
PWM, and ADC channels over a UART JSON protocol.
</p>
<p>
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.
</p>
<div class="arch-svg-wrap">
<svg viewBox="0 0 720 400" xmlns="http://www.w3.org/2000/svg" style="width:100%;font-family:'JetBrains Mono',monospace;">
<defs>
<marker id="ah" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#f59e0b"/>
</marker>
<marker id="ah2" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#f59e0b"/>
</marker>
</defs>
<!-- Pi box -->
<rect x="30" y="20" width="660" height="160" rx="8"
fill="#10131a" stroke="#06b6d4" stroke-width="1.5"/>
<!-- Pi header bar -->
<rect x="30" y="20" width="660" height="32" rx="8" fill="#06b6d4" fill-opacity="0.15"/>
<rect x="30" y="40" width="660" height="12" fill="#06b6d4" fill-opacity="0.15"/>
<text x="360" y="42" text-anchor="middle" font-size="12" font-weight="600" fill="#06b6d4" letter-spacing="1">RASPBERRY PI 5 — host</text>
<!-- main.py node -->
<rect x="60" y="72" width="90" height="28" rx="5" fill="#161b26" stroke="#1e2635" stroke-width="1"/>
<text x="105" y="90" text-anchor="middle" font-size="11" fill="#06b6d4">main.py</text>
<!-- bootstrap arrow -->
<line x1="150" y1="86" x2="192" y2="86" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#ah)"/>
<text x="170" y="80" text-anchor="middle" font-size="9" fill="#f59e0b">bootstrap</text>
<!-- robot.py -->
<rect x="193" y="72" width="148" height="28" rx="5" fill="#161b26" stroke="#1e2635" stroke-width="1"/>
<text x="267" y="87" text-anchor="middle" font-size="11" fill="#06b6d4">robot.py</text>
<text x="267" y="99" text-anchor="middle" font-size="9" fill="#64748b">L298N · HC-SR04</text>
<!-- adc.py -->
<rect x="193" y="112" width="148" height="28" rx="5" fill="#161b26" stroke="#1e2635" stroke-width="1"/>
<text x="267" y="127" text-anchor="middle" font-size="11" fill="#06b6d4">adc.py</text>
<text x="267" y="139" text-anchor="middle" font-size="9" fill="#64748b">MCP3008 bit-bang SPI</text>
<!-- coprocessor.py -->
<rect x="370" y="72" width="170" height="28" rx="5" fill="#161b26" stroke="#1e2635" stroke-width="1"/>
<text x="455" y="87" text-anchor="middle" font-size="11" fill="#06b6d4">coprocessor.py</text>
<text x="455" y="99" text-anchor="middle" font-size="9" fill="#64748b">UART → RP2040</text>
<!-- wifi_provision.py -->
<rect x="370" y="112" width="170" height="28" rx="5" fill="#161b26" stroke="#1e2635" stroke-width="1"/>
<text x="455" y="127" text-anchor="middle" font-size="11" fill="#06b6d4">wifi_provision.py</text>
<text x="455" y="139" text-anchor="middle" font-size="9" fill="#64748b">nmcli hotspot</text>
<!-- web.py -->
<rect x="570" y="92" width="100" height="28" rx="5" fill="#161b26" stroke="#3b82f6" stroke-width="1"/>
<text x="620" y="107" text-anchor="middle" font-size="11" fill="#3b82f6">web.py</text>
<text x="620" y="119" text-anchor="middle" font-size="9" fill="#64748b">Flask + SocketIO</text>
<!-- main → web arrow -->
<path d="M 105 100 Q 105 152 570 106" fill="none" stroke="#f59e0b" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#ah2)"/>
<text x="340" y="162" text-anchor="middle" font-size="9" fill="#f59e0b">serves UI</text>
<!-- UART bridge -->
<line x1="360" y1="200" x2="360" y2="240" stroke="#f59e0b" stroke-width="2" marker-end="url(#ah)"/>
<text x="360" y="228" text-anchor="middle" font-size="10" font-weight="600" fill="#f59e0b">UART 115200 8N1</text>
<text x="360" y="242" text-anchor="middle" font-size="9" fill="#94a3b8">GP14 TX ↔ GP1 RX · GP15 RX ↔ GP2 TX</text>
<!-- RP2040 box -->
<rect x="30" y="255" width="660" height="125" rx="8"
fill="#10131a" stroke="#3b82f6" stroke-width="1.5"/>
<rect x="30" y="255" width="660" height="32" rx="8" fill="#3b82f6" fill-opacity="0.12"/>
<rect x="30" y="275" width="660" height="12" fill="#3b82f6" fill-opacity="0.12"/>
<text x="360" y="277" text-anchor="middle" font-size="12" font-weight="600" fill="#3b82f6" letter-spacing="1">RP2040 — co-processor / MicroPython</text>
<!-- rp main.py -->
<rect x="60" y="303" width="90" height="28" rx="5" fill="#161b26" stroke="#1e2635" stroke-width="1"/>
<text x="105" y="321" text-anchor="middle" font-size="11" fill="#3b82f6">main.py</text>
<!-- loop arrow -->
<line x1="150" y1="317" x2="192" y2="317" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#ah)"/>
<text x="170" y="311" text-anchor="middle" font-size="9" fill="#f59e0b">loop</text>
<!-- protocol.py -->
<rect x="193" y="303" width="170" height="28" rx="5" fill="#161b26" stroke="#1e2635" stroke-width="1"/>
<text x="278" y="318" text-anchor="middle" font-size="11" fill="#3b82f6">protocol.py</text>
<text x="278" y="330" text-anchor="middle" font-size="9" fill="#64748b">JSON parse / dispatch</text>
<!-- pin_manager.py -->
<rect x="390" y="303" width="170" height="28" rx="5" fill="#161b26" stroke="#1e2635" stroke-width="1"/>
<text x="475" y="318" text-anchor="middle" font-size="11" fill="#3b82f6">pin_manager.py</text>
<text x="475" y="330" text-anchor="middle" font-size="9" fill="#64748b">GP3GP29 · ADC · PWM</text>
<!-- protocol → pin_manager -->
<line x1="363" y1="317" x2="388" y2="317" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#ah)"/>
</svg>
</div>
</section>
<!-- ARCHITECTURE -->
<section id="architecture">
<h2>Architecture</h2>
<p>
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.
</p>
<table>
<tr>
<th>Subsystem</th>
<th>Hardware</th>
<th>Pi GPIO</th>
<th>Software Owner</th>
</tr>
<tr>
<td>Motor drive</td>
<td>L298N H-bridge</td>
<td>BCM 17,27,22 (A) / 18,23,12 (B)</td>
<td><code>robot.py</code></td>
</tr>
<tr>
<td>Obstacle sensing</td>
<td>2× HC-SR04 ultrasonic</td>
<td>BCM 5,6 (front) / 13,26 (back)</td>
<td><code>robot.py</code></td>
</tr>
<tr>
<td>Analog sensing</td>
<td>MCP3008 10-bit ADC</td>
<td>BCM 16,20,35,21 (bit-bang SPI)</td>
<td><code>adc.py</code></td>
</tr>
<tr>
<td>GPIO expansion</td>
<td>RP2040 co-processor</td>
<td>BCM 14,15 (UART ttyAMA0)</td>
<td><code>coprocessor.py</code></td>
</tr>
</table>
<p>
The web layer (<code>web.py</code>) is a thin adapter: it
translates WebSocket events from the browser into method
calls on <code>RobotController</code>, <code>MCP3008</code>,
and <code>RP2040</code> objects — similar to how your
MQTT/Redis gateway translated upstream messages into RS-485
bus commands.
</p>
</section>
<!-- MODULE MAP -->
<section id="modules">
<h2>Module Map</h2>
<div class="module-grid">
<div class="module-card">
<div class="filename">lumio/main.py</div>
<div class="desc">
Entry point. Instantiates all hardware objects, runs
WiFi provisioning, then launches the Flask/SocketIO
server. Boot sequence is: hardware → WiFi → web.
</div>
</div>
<div class="module-card">
<div class="filename">lumio/config.py</div>
<div class="desc">
Single source of truth for ALL pin numbers, motion
parameters, and network constants. Edit only this
file when rewiring.
</div>
</div>
<div class="module-card">
<div class="filename">lumio/robot.py</div>
<div class="desc">
L298N motor driver and HC-SR04 ultrasonic sensor
interface. Provides high-level movement primitives:
<code>move_forward</code>, <code>turn</code>,
<code>dance</code>, etc. Includes
<code>_FakeGPIO</code> stub for dev machines.
</div>
</div>
<div class="module-card">
<div class="filename">lumio/adc.py</div>
<div class="desc">
Bit-banged SPI driver for the MCP3008. Reads 8
analog channels at 10-bit resolution. Returns raw
codes (01023) or voltages (0.03.3 V).
</div>
</div>
<div class="module-card">
<div class="filename">lumio/coprocessor.py</div>
<div class="desc">
Non-blocking UART interface to the RP2040.
Background thread reads unsolicited events;
synchronous commands use request-reply with timeout.
</div>
</div>
<div class="module-card">
<div class="filename">lumio/web.py</div>
<div class="desc">
Flask + Flask-SocketIO server. REST endpoint for
status/GPIO config. WebSocket events for commands
and sensor streaming.
</div>
</div>
<div class="module-card">
<div class="filename">lumio/wifi_provision.py</div>
<div class="desc">
Boot-time WiFi provisioning: checks for saved
credentials, starts AP hotspot + captive portal if
none found, saves new credentials to
<code>/etc/lumio/wifi.conf</code> via nmcli.
</div>
</div>
<div class="module-card">
<div class="filename">lumio-rp2040/main.py</div>
<div class="desc">
RP2040 MicroPython entry point. UART read loop on
GP1/GP2 at 115200 baud. Polls all input pins every
20 ms for edge events.
</div>
</div>
<div class="module-card">
<div class="filename">lumio-rp2040/protocol.py</div>
<div class="desc">
JSON command dispatcher. Parses incoming frames,
calls into <code>PinManager</code>, serialises
replies. Also handles async event push to host.
</div>
</div>
<div class="module-card">
<div class="filename">lumio-rp2040/pin_manager.py</div>
<div class="desc">
Runtime GPIO state machine. Each pin is a
<code>PinState</code> object with mode, current
machine object, and edge-detection state. Supports:
input, output, ADC, PWM, unused.
</div>
</div>
</div>
</section>
<hr />
<!-- PI-SIDE MODULES -->
<section id="main">
<h2>Pi — <code>main.py</code> · Entry Point</h2>
<p>
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.
</p>
<h3>Boot Sequence</h3>
<div class="flow">
<div class="flow-node">Logging setup</div>
<div class="flow-arrow"></div>
<div class="flow-node">RobotController()</div>
<div class="flow-arrow"></div>
<div class="flow-node">MCP3008()</div>
<div class="flow-arrow"></div>
<div class="flow-node">RP2040()</div>
<div class="flow-arrow"></div>
<div class="flow-node">ensure_wifi()</div>
<div class="flow-arrow"></div>
<div class="flow-node">socketio.run()</div>
</div>
<h3>Key Behaviour</h3>
<p>
If <code>rp.ping()</code> 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.
</p>
<h3>Environment Variables</h3>
<table>
<tr>
<th>Variable</th>
<th>Default</th>
<th>Effect</th>
</tr>
<tr>
<td><code>LUMIO_PORT</code></td>
<td>80</td>
<td>HTTP port for the web server</td>
</tr>
<tr>
<td><code>LUMIO_LOG</code></td>
<td>/var/log/lumio/lumio.log</td>
<td>Log file path</td>
</tr>
</table>
</section>
<section id="config">
<h2>Pi — <code>config.py</code> · Pin Definitions</h2>
<p>
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.
</p>
<h3>Pin Groups</h3>
<table>
<tr>
<th>Group</th>
<th>Constants</th>
<th>Notes</th>
</tr>
<tr>
<td>Motor A (left)</td>
<td><code>MOTOR_A_IN1=17, IN2=27, ENA=22</code></td>
<td>ENA is hardware PWM on Pi 5</td>
</tr>
<tr>
<td>Motor B (right)</td>
<td><code>MOTOR_B_IN3=18, IN4=23, ENB=12</code></td>
<td>ENB is hardware PWM on Pi 5</td>
</tr>
<tr>
<td>Front US</td>
<td><code>FRONT_TRIG=5, FRONT_ECHO=6</code></td>
<td></td>
</tr>
<tr>
<td>Back US</td>
<td><code>BACK_TRIG=13, BACK_ECHO=26</code></td>
<td>Changed from old REAR_ECHO=19</td>
</tr>
<tr>
<td>MCP3008</td>
<td><code>CS=16, MOSI=20, MISO=35, CLK=21</code></td>
<td>⚠ BCM 35 — see Issues</td>
</tr>
<tr>
<td>RP2040 UART</td>
<td><code>PORT=/dev/ttyAMA0, BAUD=115200</code></td>
<td>BCM 14/15</td>
</tr>
<tr>
<td>I2C</td>
<td><code>SCL=3, SDA=2, BUS=1</code></td>
<td></td>
</tr>
</table>
<h3>Motion Constants</h3>
<table>
<tr>
<th>Constant</th>
<th>Value</th>
<th>Used in</th>
</tr>
<tr>
<td><code>LINEAR_SPEED_MPS</code></td>
<td>0.7 m/s</td>
<td><code>move_forward/backward</code> timing</td>
</tr>
<tr>
<td><code>ROTATION_SPEED_DPS</code></td>
<td>270 °/s</td>
<td><code>turn()</code> timing</td>
</tr>
<tr>
<td><code>OBSTACLE_THRESHOLD_CM</code></td>
<td>20 cm</td>
<td>Emergency stop trigger</td>
</tr>
<tr>
<td><code>PWM_FREQUENCY_HZ</code></td>
<td>200 Hz</td>
<td>Motor enable PWM frequency</td>
</tr>
</table>
</section>
<section id="robot">
<h2>Pi — <code>robot.py</code> · Motor + Sensor Control</h2>
<p>
Controls the L298N H-bridge and both HC-SR04 ultrasonic
sensors. The class is thread-safe: all movement methods
acquire <code>self._lock</code>, so the web server can't
issue two simultaneous movement commands and cause GPIO
conflicts.
</p>
<h3>Public API</h3>
<table>
<tr>
<th>Method</th>
<th>Signature</th>
<th>Returns</th>
<th>Notes</th>
</tr>
<tr>
<td><code>move_forward</code></td>
<td><code>(distance_m=1.0, duty=50)</code></td>
<td>bool</td>
<td>False = blocked by obstacle</td>
</tr>
<tr>
<td><code>move_backward</code></td>
<td><code>(distance_m=1.0, duty=50)</code></td>
<td>bool</td>
<td>False = blocked by obstacle</td>
</tr>
<tr>
<td><code>turn</code></td>
<td><code>(angle_deg, duty=50)</code></td>
<td>bool</td>
<td>+ve = CW (right), ve = CCW (left)</td>
</tr>
<tr>
<td><code>stop</code></td>
<td><code>()</code></td>
<td>None</td>
<td>Immediate, no nudge</td>
</tr>
<tr>
<td><code>emergency_stop</code></td>
<td><code>(obstacle_front=True)</code></td>
<td>None</td>
<td>Stops + nudges away from obstacle</td>
</tr>
<tr>
<td><code>dance</code></td>
<td><code>()</code></td>
<td>bool</td>
<td>Pre-programmed routine</td>
</tr>
<tr>
<td><code>say_hi</code></td>
<td><code>()</code></td>
<td>bool</td>
<td>Pre-programmed wave</td>
</tr>
<tr>
<td><code>cleanup</code></td>
<td><code>()</code></td>
<td>None</td>
<td>Call on shutdown</td>
</tr>
</table>
<h3>Distance Measurement</h3>
<p>
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.
</p>
<div class="ok-card">
<div class="card-title">
✓ FIXED — t_start / t_end moved outside while-loop body
</div>
<div class="card-body">
Both timestamps are now assigned <em>after</em> 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.
<pre><span class="cm"># Current (fixed) code</span>
<span class="kw">while</span> GPIO.input(echo_pin) == <span class="num">0</span>:
<span class="kw">if</span> time.monotonic() > deadline: <span class="kw">return</span> <span class="num">999.0</span>
t_start = time.monotonic() <span class="cm"># set after loop exits — correct</span>
<span class="kw">while</span> GPIO.input(echo_pin) == <span class="num">1</span>:
<span class="kw">if</span> time.monotonic() > deadline: <span class="kw">return</span> <span class="num">999.0</span>
t_end = time.monotonic() <span class="cm"># set after loop exits — correct</span></pre>
</div>
</div>
<h3>Simulation Mode</h3>
<p>
If <code>RPi.GPIO</code> is not installed (dev machine, CI),
the module falls back to <code>_FakeGPIO</code> — a minimal
stub that silently no-ops all GPIO calls. This lets you run
and test the web server without physical hardware.
</p>
</section>
<section id="adc">
<h2>Pi — <code>adc.py</code> · MCP3008 ADC Driver</h2>
<p>
A pure bit-bang SPI driver — no kernel SPI device, no
<code>spidev</code> 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.
</p>
<p>
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
<code>&amp; 0x3FF</code>.
</p>
<h3>Public API</h3>
<table>
<tr>
<th>Method</th>
<th>Args</th>
<th>Returns</th>
</tr>
<tr>
<td><code>read_raw</code></td>
<td><code>channel: int (0-7)</code></td>
<td>int 01023</td>
</tr>
<tr>
<td><code>read_voltage</code></td>
<td><code>channel: int (0-7)</code></td>
<td>float 0.03.3 V</td>
</tr>
<tr>
<td><code>read_all_raw</code></td>
<td></td>
<td>dict {0..7: int}</td>
</tr>
<tr>
<td><code>read_all_voltage</code></td>
<td></td>
<td>dict {0..7: float}</td>
</tr>
</table>
<div class="warn-card">
<div class="card-title">
⚠ WARNING — BCM GPIO 35 for MISO
</div>
<div class="card-body">
<code>ADC_MISO = 35</code> is set in
<code>config.py</code>. 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 <code>gpio readall</code> before first
power-on to avoid a short.
</div>
</div>
</section>
<section id="coprocessor">
<h2>
Pi — <code>coprocessor.py</code> · RP2040 UART Interface
</h2>
<p>
A non-blocking serial interface. A background daemon thread
reads continuously from <code>/dev/ttyAMA0</code> and splits
on newlines — identical in concept to the background reader
thread in your RS-485 MQTT gateway. Synchronous commands use
a <code>threading.Event</code> as a one-shot signal: send,
wait for event with timeout, return the last-received reply.
</p>
<h3>Threading Model</h3>
<div class="flow">
<div class="flow-node">Web handler thread</div>
<div class="flow-arrow"></div>
<div class="flow-node">send() acquires _lock</div>
<div class="flow-arrow"></div>
<div class="flow-node">writes JSON to UART</div>
<div class="flow-arrow"></div>
<div class="flow-node">waits on _reply_event</div>
</div>
<div class="flow" style="margin-top: 6px">
<div class="flow-node">_read_loop thread</div>
<div class="flow-arrow"></div>
<div class="flow-node">parses reply frame</div>
<div class="flow-arrow"></div>
<div class="flow-node">sets _reply_event</div>
<div class="flow-arrow"></div>
<div class="flow-node">send() returns</div>
</div>
<h3>Public API</h3>
<table>
<tr>
<th>Method</th>
<th>Signature</th>
<th>Returns</th>
</tr>
<tr>
<td><code>ping</code></td>
<td><code>()</code></td>
<td>bool</td>
</tr>
<tr>
<td><code>gpio_set_mode</code></td>
<td>
<code>(pin: int, mode: str, label: str = "")</code>
</td>
<td>bool</td>
</tr>
<tr>
<td><code>gpio_write</code></td>
<td><code>(pin: int, value: int)</code></td>
<td>bool</td>
</tr>
<tr>
<td><code>gpio_read</code></td>
<td><code>(pin: int)</code></td>
<td>int | None</td>
</tr>
<tr>
<td><code>adc_read</code></td>
<td>
<code>(pin: int)</code> — GP26GP29 only, must be in
adc mode
</td>
<td>float | None</td>
</tr>
<tr>
<td><code>on_event</code></td>
<td><code>(callback: Callable[[dict], None])</code></td>
<td></td>
</tr>
<tr>
<td><code>send</code></td>
<td><code>(payload: dict)</code></td>
<td>dict | None</td>
</tr>
<tr>
<td><code>close</code></td>
<td><code>()</code></td>
<td></td>
</tr>
</table>
<div class="ok-card">
<div class="card-title">
✓ FIXED — adc_read now sends "pin" key; gpio_set_mode()
added
</div>
<div class="card-body">
<code>adc_read(pin)</code> now sends
<code>{"cmd": "adc_read", "pin": pin}</code>, matching
what the firmware's
<code>frame.get("pin")</code> expects. The parameter was
also renamed from <code>channel</code> to
<code>pin</code> to reflect that GP26GP29 are passed as
pin numbers, not channel indices.<br /><br />
<code>gpio_set_mode(pin, mode, label="")</code> was
added as a new convenience wrapper — it was missing
entirely but required by <code>web.py</code>'s GPIO
config endpoint:
<pre><span class="kw">def</span> <span class="fn">gpio_set_mode</span>(self, pin: <span class="fn">int</span>, mode: <span class="fn">str</span>, label: <span class="fn">str</span> = <span class="str">""</span>) -> <span class="fn">bool</span>:
reply = self.send({<span class="str">"cmd"</span>: <span class="str">"gpio_set_mode"</span>, <span class="str">"pin"</span>: pin, <span class="str">"mode"</span>: mode, <span class="str">"label"</span>: label})
<span class="kw">return</span> <span class="fn">bool</span>(reply <span class="kw">and</span> reply.get(<span class="str">"ok"</span>))
<span class="kw">def</span> <span class="fn">adc_read</span>(self, pin: <span class="fn">int</span>) -> <span class="fn">float</span> | <span class="kw">None</span>:
reply = self.send({<span class="str">"cmd"</span>: <span class="str">"adc_read"</span>, <span class="str">"pin"</span>: pin}) <span class="cm"># "pin", not "ch"</span>
<span class="kw">if</span> reply <span class="kw">and</span> reply.get(<span class="str">"ok"</span>):
<span class="kw">return</span> reply.get(<span class="str">"value"</span>)
<span class="kw">return</span> <span class="kw">None</span></pre>
</div>
</div>
</section>
<section id="web">
<h2>Pi — <code>web.py</code> · Flask / SocketIO Server</h2>
<p>
A thin integration layer. The Flask app is created at module
level (not inside the factory) which means the global
<code>app</code> and <code>socketio</code> objects exist
before <code>create_app()</code> is called — this is a
standard Flask pattern, but means you can't run two server
instances in one process.
</p>
<h3>HTTP Endpoints</h3>
<table>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
<tr>
<td>GET</td>
<td><code>/</code></td>
<td>Robot control dashboard (control.html)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/gpio</code></td>
<td>RP2040 GPIO programming panel (gpio.html)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/status</code></td>
<td>
JSON snapshot: front/back distance, ADC voltages,
RP2040 online flag
</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/gpio/config</code></td>
<td>
Set RP2040 pin modes; body:
<code
>{"configs": [{"pin": N, "mode":
"input|output|adc|pwm|unused", "label":
"..."}]}</code
>
</td>
</tr>
</table>
<h3>WebSocket Events</h3>
<table>
<tr>
<th>Direction</th>
<th>Event</th>
<th>Payload</th>
</tr>
<tr>
<td>Client → Server</td>
<td><code>command</code></td>
<td>
<code
>{"type":
"forward|backward|left|right|dance|hi|stop",
"value": float, "duty": int}</code
>
</td>
</tr>
<tr>
<td>Client → Server</td>
<td><code>get_sensor</code></td>
<td>
<code
>{"type": "front|back|adc|rp_gpio", "ch": int,
"pin": int}</code
>
</td>
</tr>
<tr>
<td>Server → Client</td>
<td><code>command_result</code></td>
<td><code>{"ok": bool, "cmd": str}</code></td>
</tr>
<tr>
<td>Server → Client</td>
<td><code>sensor_data</code></td>
<td>
<code
>{"type": str, "cm": float, "raw": int,
"voltage": float}</code
>
</td>
</tr>
<tr>
<td>Server → Client</td>
<td><code>rp2040_event</code></td>
<td>Forwarded RP2040 async event frame</td>
</tr>
</table>
<div class="ok-card">
<div class="card-title">
✓ FIXED — Templates use render_template(); no more
CWD-relative open()
</div>
<div class="card-body">
Both route handlers now use Flask's
<code>render_template()</code>, which resolves paths
against the
<code>template_folder="../templates"</code> already
configured on the app object. The service starts
correctly regardless of working directory, and the file
handle is never left open.
<pre><span class="cm"># Current (fixed)</span>
<span class="kw">from</span> flask <span class="kw">import</span> Flask, render_template, jsonify, request
<span class="fn">@app.route</span>(<span class="str">"/"</span>)
<span class="kw">def</span> <span class="fn">index</span>():
<span class="kw">return</span> render_template(<span class="str">"control.html"</span>)
<span class="fn">@app.route</span>(<span class="str">"/gpio"</span>)
<span class="kw">def</span> <span class="fn">gpio_page</span>():
<span class="kw">return</span> render_template(<span class="str">"gpio.html"</span>)</pre>
</div>
</div>
<div class="ok-card">
<div class="card-title">
✓ FIXED — /api/gpio/config calls gpio_set_mode()
correctly
</div>
<div class="card-body">
The GPIO config endpoint now calls
<code>_rp.gpio_set_mode(pin, mode, label)</code>,
sending the correct <code>gpio_set_mode</code> command
to the firmware. The bogus <code>value</code> integer
and the misrouted <code>gpio_write</code> call are
removed. All five modes (<code>input</code>,
<code>output</code>, <code>adc</code>, <code>pwm</code>,
<code>unused</code>) are now properly forwarded.
<pre><span class="cm"># Current (fixed)</span>
<span class="kw">for</span> cfg <span class="kw">in</span> configs:
pin = cfg.get(<span class="str">"pin"</span>)
mode = cfg.get(<span class="str">"mode"</span>, <span class="str">"input"</span>) <span class="cm"># input|output|adc|pwm|unused</span>
label = cfg.get(<span class="str">"label"</span>, <span class="str">""</span>)
ok = _rp.gpio_set_mode(pin, mode, label)
results.append({<span class="str">"pin"</span>: pin, <span class="str">"ok"</span>: ok})</pre>
</div>
</div>
</section>
<section id="wifi">
<h2>Pi — <code>wifi_provision.py</code> · WiFi Setup</h2>
<p>
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.
</p>
<h3>Boot Decision Tree</h3>
<div class="flow">
<div class="flow-node">Check /etc/lumio/wifi.conf</div>
<div class="flow-arrow"></div>
<div class="flow-node">nmcli connect</div>
<div class="flow-arrow"></div>
<div class="flow-node">Success? Return True</div>
</div>
<div class="flow" style="margin-top: 6px; margin-left: 20px">
<div class="flow-arrow">↓ fail</div>
</div>
<div class="flow" style="margin-top: 6px">
<div class="flow-node">Start hotspot (nmcli)</div>
<div class="flow-arrow"></div>
<div class="flow-node">Serve portal on :8080</div>
<div class="flow-arrow"></div>
<div class="flow-node">User submits SSID+pw</div>
<div class="flow-arrow"></div>
<div class="flow-node">Save → nmcli → teardown hotspot</div>
</div>
<h3>Defaults</h3>
<table>
<tr>
<th>Constant</th>
<th>Value</th>
</tr>
<tr>
<td>Hotspot SSID</td>
<td><code>Lumio-Setup</code></td>
</tr>
<tr>
<td>Hotspot Password</td>
<td><code>lumio1234</code></td>
</tr>
<tr>
<td>Portal IP</td>
<td><code>192.168.50.1</code></td>
</tr>
<tr>
<td>Portal Port</td>
<td><code>8080</code></td>
</tr>
<tr>
<td>Credentials file</td>
<td><code>/etc/lumio/wifi.conf</code></td>
</tr>
</table>
</section>
<hr />
<!-- RP2040 FIRMWARE -->
<section id="fw-main">
<h2>RP2040 — <code>main.py</code> · Firmware Entry Point</h2>
<p>
MicroPython entry point. Creates a UART on GP1(TX)/GP2(RX)
at 115200 baud, instantiates <code>PinManager</code> and
<code>Protocol</code>, then runs a tight loop: read UART →
process complete JSON lines → poll inputs for edge events
every 20 ms.
</p>
<pre><span class="cm"># Main loop structure</span>
<span class="kw">while</span> True:
chunk = uart.read(<span class="num">256</span>)
<span class="kw">if</span> chunk:
buf += chunk
<span class="kw">while</span> b<span class="str">"\n"</span> <span class="kw">in</span> buf:
line, buf = buf.split(b<span class="str">"\n"</span>, <span class="num">1</span>)
proto.handle(line) <span class="cm"># dispatch command</span>
<span class="kw">if</span> ticks_diff(now, last_poll) >= POLL_MS:
pm.poll_events(proto.push_event) <span class="cm"># check input edges</span>
sleep_ms(<span class="num">1</span>)</pre>
<p>
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.
</p>
</section>
<section id="fw-protocol">
<h2>RP2040 — <code>protocol.py</code> · Command Dispatcher</h2>
<p>
A pure dispatcher: it owns the UART send path and maps
command strings to <code>PinManager</code> 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.
</p>
<h3>Command Handling Flow</h3>
<div class="flow">
<div class="flow-node">raw bytes from main.py</div>
<div class="flow-arrow"></div>
<div class="flow-node">ujson.loads(raw)</div>
<div class="flow-arrow"></div>
<div class="flow-node">frame.get("cmd")</div>
<div class="flow-arrow"></div>
<div class="flow-node">PinManager method</div>
<div class="flow-arrow"></div>
<div class="flow-node">_ok() or _err()</div>
</div>
</section>
<section id="fw-pinmgr">
<h2>
RP2040 — <code>pin_manager.py</code> · GPIO State Machine
</h2>
<p>
Each pin is a <code>PinState</code> object that holds its
current mode, the live MicroPython machine object
(<code>Pin</code>, <code>ADC</code>, or <code>PWM</code>),
and last-known value for edge detection. The
<code>release()</code> method calls
<code>PWM.deinit()</code> before reconfiguring — like
properly closing a file before reopening it in a different
mode.
</p>
<h3>Pin Availability</h3>
<table>
<tr>
<th>Pins</th>
<th>Status</th>
<th>Capabilities</th>
</tr>
<tr>
<td>GP1, GP2</td>
<td>
<span class="badge badge-danger">RESERVED</span>
</td>
<td>UART TX/RX — never reassignable</td>
</tr>
<tr>
<td>GP3GP25</td>
<td><span class="badge badge-ok">FREE</span></td>
<td>Digital input, output, PWM</td>
</tr>
<tr>
<td>GP26GP29</td>
<td><span class="badge badge-ok">FREE</span></td>
<td>Digital + ADC (channels 03)</td>
</tr>
</table>
<h3>PinState API</h3>
<table>
<tr>
<th>Method</th>
<th>Valid Modes</th>
<th>Returns</th>
</tr>
<tr>
<td><code>configure(mode, label)</code></td>
<td>any</td>
<td>bool</td>
</tr>
<tr>
<td><code>digital_read()</code></td>
<td>input, output</td>
<td>int | None</td>
</tr>
<tr>
<td><code>digital_write(value)</code></td>
<td>output</td>
<td>bool</td>
</tr>
<tr>
<td><code>adc_read_u16()</code></td>
<td>adc</td>
<td>int 065535 | None</td>
</tr>
<tr>
<td><code>adc_read_voltage()</code></td>
<td>adc</td>
<td>float 0.03.3 V | None</td>
</tr>
<tr>
<td><code>pwm_set(freq, duty)</code></td>
<td>pwm</td>
<td>bool</td>
</tr>
<tr>
<td><code>check_edge()</code></td>
<td>input</td>
<td>(changed: bool, value: int|None)</td>
</tr>
</table>
<p>
Note: inputs are initialised with
<code>Pin.PULL_DOWN</code>. If your sensor requires pull-up,
you'll need to pass <code>Pin.PULL_UP</code> in
<code>configure()</code> or use an external resistor.
</p>
</section>
<section id="fw-protocol-ref">
<h2>RP2040 ↔ Pi — Full Protocol Reference</h2>
<p>
All messages are newline-terminated JSON (<code>\n</code>).
The Pi is always master: it sends commands, the RP2040
replies synchronously. The RP2040 can additionally push
unsolicited <code>event</code> frames when an input pin
changes state.
</p>
<h3>Commands (Pi → RP2040)</h3>
<pre><span class="cm">// Health check</span>
{<span class="str">"cmd"</span>: <span class="str">"ping"</span>}
<span class="cm">// Configure a pin's mode and optional label</span>
{<span class="str">"cmd"</span>: <span class="str">"gpio_set_mode"</span>, <span class="str">"pin"</span>: <span class="num">5</span>, <span class="str">"mode"</span>: <span class="str">"input"</span>, <span class="str">"label"</span>: <span class="str">"IR sensor"</span>}
{<span class="str">"cmd"</span>: <span class="str">"gpio_set_mode"</span>, <span class="str">"pin"</span>: <span class="num">6</span>, <span class="str">"mode"</span>: <span class="str">"output"</span>, <span class="str">"label"</span>: <span class="str">"LED"</span>}
{<span class="str">"cmd"</span>: <span class="str">"gpio_set_mode"</span>, <span class="str">"pin"</span>: <span class="num">26</span>, <span class="str">"mode"</span>: <span class="str">"adc"</span>}
{<span class="str">"cmd"</span>: <span class="str">"gpio_set_mode"</span>, <span class="str">"pin"</span>: <span class="num">7</span>, <span class="str">"mode"</span>: <span class="str">"pwm"</span>}
{<span class="str">"cmd"</span>: <span class="str">"gpio_set_mode"</span>, <span class="str">"pin"</span>: <span class="num">8</span>, <span class="str">"mode"</span>: <span class="str">"unused"</span>}
<span class="cm">// Digital I/O</span>
{<span class="str">"cmd"</span>: <span class="str">"gpio_read"</span>, <span class="str">"pin"</span>: <span class="num">5</span>}
{<span class="str">"cmd"</span>: <span class="str">"gpio_write"</span>, <span class="str">"pin"</span>: <span class="num">6</span>, <span class="str">"value"</span>: <span class="num">1</span>}
<span class="cm">// ADC (pin must be GP26GP29 in adc mode) — key is "pin", not "ch"!</span>
{<span class="str">"cmd"</span>: <span class="str">"adc_read"</span>, <span class="str">"pin"</span>: <span class="num">26</span>}
<span class="cm">// PWM (duty: 065535 u16 resolution)</span>
{<span class="str">"cmd"</span>: <span class="str">"pwm_set"</span>, <span class="str">"pin"</span>: <span class="num">7</span>, <span class="str">"freq"</span>: <span class="num">50</span>, <span class="str">"duty"</span>: <span class="num">4915</span>}
<span class="cm">// Introspection</span>
{<span class="str">"cmd"</span>: <span class="str">"list_pins"</span>} <span class="cm">// all assignable pins</span>
{<span class="str">"cmd"</span>: <span class="str">"get_all"</span>} <span class="cm">// only non-unused pins</span></pre>
<h3>Replies (RP2040 → Pi)</h3>
<pre>{<span class="str">"ok"</span>: <span class="kw">true</span>}
{<span class="str">"ok"</span>: <span class="kw">true</span>, <span class="str">"value"</span>: <span class="num">1</span>} <span class="cm">// gpio_read</span>
{<span class="str">"ok"</span>: <span class="kw">true</span>, <span class="str">"value"</span>: <span class="num">1.6523</span>} <span class="cm">// adc_read (voltage, 3.3V ref)</span>
{<span class="str">"ok"</span>: <span class="kw">true</span>, <span class="str">"pins"</span>: [{<span class="str">"pin"</span>: <span class="num">5</span>, <span class="str">"mode"</span>: <span class="str">"input"</span>, <span class="str">"label"</span>: <span class="str">"IR"</span>, <span class="str">"value"</span>: <span class="num">0</span>}, ...]}
{<span class="str">"ok"</span>: <span class="kw">false</span>, <span class="str">"error"</span>: <span class="str">"pin 26 not in adc mode"</span>}</pre>
<h3>Async Events (RP2040 → Pi, unsolicited)</h3>
<pre>{<span class="str">"event"</span>: <span class="str">"change"</span>, <span class="str">"pin"</span>: <span class="num">5</span>, <span class="str">"value"</span>: <span class="num">1</span>} <span class="cm">// rising edge</span>
{<span class="str">"event"</span>: <span class="str">"change"</span>, <span class="str">"pin"</span>: <span class="num">5</span>, <span class="str">"value"</span>: <span class="num">0</span>} <span class="cm">// falling edge</span></pre>
</section>
<hr />
<!-- CHANGELOG -->
<section id="bugs">
<h2>✓ Resolved Issues — Changelog</h2>
<p>
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.
</p>
<div class="ok-card">
<div class="card-title">
✓ FIX 1 — <code>robot.py</code> · t_start / t_end moved
outside while-loop body
</div>
<div class="card-body">
<strong>File:</strong>
<code>lumio/lumio/robot.py</code>,
<code>measure_distance_cm()</code><br /><br />
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.
</div>
</div>
<div class="ok-card">
<div class="card-title">
✓ FIX 2 — <code>coprocessor.py</code> · adc_read key
corrected from "ch" to "pin"
</div>
<div class="card-body">
<strong>File:</strong>
<code>lumio/lumio/coprocessor.py</code>,
<code>adc_read()</code><br /><br />
Key mismatch between Pi and firmware: Pi was sending
<code>"ch"</code>, firmware reads
<code>frame.get("pin")</code>. All co-processor ADC
reads were silently returning error responses. Fixed to
<code>"pin"</code>; parameter renamed from
<code>channel</code> to <code>pin</code> accordingly.
</div>
</div>
<div class="ok-card">
<div class="card-title">
✓ FIX 3 — <code>coprocessor.py</code> · gpio_set_mode()
added
</div>
<div class="card-body">
<strong>File:</strong>
<code>lumio/lumio/coprocessor.py</code><br /><br />
The method was missing entirely but required by
<code>web.py</code>'s GPIO config endpoint. Added as a
new convenience wrapper that sends
<code
>{"cmd": "gpio_set_mode", "pin": ..., "mode": ...,
"label": ...}</code
>
— a command the firmware already fully supported.
</div>
</div>
<div class="ok-card">
<div class="card-title">
✓ FIX 4 — <code>web.py</code> · render_template() +
gpio_set_mode() in config endpoint
</div>
<div class="card-body">
<strong>File:</strong> <code>lumio/lumio/web.py</code
><br /><br />
Two fixes in one file: (1) Both route handlers replaced
<code>render_template_string(open(...))</code> with
<code>render_template()</code>, which resolves paths
correctly via Flask's template folder regardless of CWD.
(2) The <code>/api/gpio/config</code> endpoint replaced
the misrouted <code>gpio_write</code> call with
<code>gpio_set_mode(pin, mode, label)</code>.
</div>
</div>
<div class="warn-card">
<div class="card-title">
⚠ OPEN — <code>config.py</code> · BCM 35 for ADC_MISO —
verify against your board
</div>
<div class="card-body">
<strong>File:</strong>
<code>lumio/lumio/config.py</code>, line 28<br /><br />
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
<code>gpio readall</code> before powering on. A wrong
pin could cause a GPIO conflict or silently broken SPI
reads. No code change made — requires hardware
confirmation first.
</div>
</div>
<div class="ok-card">
<div class="card-title">✓ Everything else is solid</div>
<div class="card-body">
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 <code>gpio_read</code>,
<code>gpio_write</code>, <code>ping</code>,
<code>list_pins</code>, <code>get_all</code>, and async
events. PWM duty clamping. Pin reservation enforcement.
Edge detection state machine. Systemd service file is
correct. Dependency management via <code>uv</code>/<code
>pyproject.toml</code
>
is clean.
</div>
</div>
</section>
<hr />
</main>
<script>
// Highlight active nav link on scroll
const sections = document.querySelectorAll("section[id], div[id]");
const navLinks = document.querySelectorAll("nav a");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
navLinks.forEach((a) => {
a.classList.toggle(
"active",
a.getAttribute("href") ===
"#" + e.target.id,
);
});
}
});
},
{ rootMargin: "-20% 0px -70% 0px" },
);
sections.forEach((s) => observer.observe(s));
</script>
</body>
</html>