The BluePrint Platform: How a $6 Microcontroller Got a Declarative UI Engine
J. Rogers — SE Ohio
The code is here: https://github.com/BuckRogers1965/Pico-IoT-Replacement/tree/main/WeatherStation
When I started this project, the Raspberry Pi Pico W had a hardcoded web page with four fixed cards. Sensors landed in whichever card matched their type. Moving a sensor meant editing raw HTML inside the framework. Adding a page meant rewriting the entire web server handler. It worked, but it wasn’t a platform — it was a project.
Today that changed.
What Was There Before
The firmware already had serious engineering behind it. Dual-core Asymmetric Multiprocessing keeps Core 1 running the irrigation state machine and sensor polling completely isolated from network activity on Core 0. A lock-free hardware FIFO bridges the two cores without mutexes — no core ever blocks waiting for the other. Zero-allocation chunked streaming means web pages are sent directly from Flash to the network buffer in small pieces, with no large HTML strings ever sitting in heap memory. A captive portal handles first-boot WiFi provisioning without a single hardcoded credential.
That infrastructure was solid. What it was missing was a UI system worthy of it.
The Three-Table Architecture
The fundamental insight driving today’s work is View-Model
Decoupling. The Registry — the existing flat array of sensors
and controls — is the Model. It knows nothing about how its data is
presented. It never will. What was added today is a completely separate
View Definition: a flat array of LayoutNode structs
declared by the application developer in their .ino
file.
This creates three tables, each with a distinct responsibility:
The Registry (app_register_items())
defines what the device knows: sensor IDs, polling intervals,
hardware callbacks, control ranges. This has not changed and will not
change. Core 1 reads and writes it. The web server serves it. It has no
knowledge of HTML.
The Layout Table (layout_table[])
defines what the website looks like: pages, cards, containers,
and which registry items map to which widgets. A developer declares a
tree structure using simple parent-child string relationships. Pages,
collapsible sections, radio button groups, tabbed containers — all
declared as flat array entries with a parent ID string. No HTML. No CSS.
No framework knowledge required.
The Help Table (help_table[]) defines
what the website explains: static HTML strings stored in Flash
and referenced by ID. These stream directly to the browser on demand.
They never touch RAM.
Boot-Time Resolution
When the device boots, after registry.begin() loads the
sensor definitions, setupLayoutResolution() runs a single
forward pass over the layout table. Every registry_id
string in the layout table is resolved to a numeric index using
registry.nameToIdx(). That is the only moment string
comparisons happen. Every runtime operation after that — rendering, data
serving, JS generation — uses only numeric array indices. O(1) lookups
at runtime, O(N) string work done once at boot.
If a developer misspells a registry ID in the layout table, it’s caught here, logged to the serial port immediately, and the device boots anyway with the broken node silently skipped. Deterministic failure at startup, not a mystery during a user’s browser session.
The same resolution pass parses the props string for
each node — "min:0,max:100" splits into structured fields
on the ResolvedNode. The Flash-resident strings are never
touched again after this point.
The Recursive Streaming Renderer
The renderer treats the resolved table as a tree and walks it depth-first. When it encounters a container node it opens the appropriate HTML wrapper and recurses into its children. When it encounters a leaf widget node it streams the widget HTML directly. When it’s done with a container’s children it closes the wrapper and continues up the tree.
Every sendContent() call streams a small chunk directly
to the network buffer. No String concatenation building large buffers.
No heap allocation. Consistent with the zero-allocation architecture
already established for the rest of the framework.
Page endpoints are registered dynamically at startup — one URL per
W_PAGE node found in the layout table. The root URL
redirects to the first page. Navigation bars are generated by scanning
for all W_PAGE nodes and emitting links. There is no
hardcoded routing anywhere.
Widget Types
The renderer supports a full set of widget and container types:
Containers: W_ROOT,
W_PAGE, W_CARD, W_COLLAPSIBLE,
W_TABBED, W_RADIO
Sensor displays: W_TEXT (plain
numeric), W_BAR (horizontal progress bar with min/max
scaling), W_DIAL (SVG arc gauge using
pathLength="1" normalization for correct scaling at any
size)
Controls: W_SLIDER,
W_BUTTON
Documentation: W_HELP (ⓘ icon with CSS
hover tooltip), W_HTML (static HTML streamed inline)
The dial implementation deserves a specific note. Using
pathLength="1" on the SVG path normalizes the coordinate
space so stroke-dasharray="1" always equals the full arc
regardless of its actual pixel length. stroke-dashoffset
then runs from 1 (empty) to 0 (full)
proportionally. No arc length calculation. No magic numbers. The browser
handles the geometry.
The JavaScript Contract
The client-side JavaScript is generated server-side per page. The
page index list — which registry indices belong to this page — is baked
into the JavaScript at serve time, not computed in the browser. On page
load, the manifest is fetched once to build an index-to-ID map. After
that, every 5-second refresh is a single /api/data?idx=...
request returning only the values this page needs. One request per
refresh cycle. No nested fetches, no parallel requests flooding the
single-threaded web server.
This replaced an earlier approach that made a separate
/api/idxname call per sensor value per refresh cycle. On a
microcontroller with a single-threaded web server, that cascade of
parallel requests caused connection resets. The manifest-based map
eliminates the problem entirely.
What the Developer Writes
Here is the complete UI definition for a two-page weather station dashboard with sensor dials, bars, collapsible sections, and inline documentation:
const LayoutNode layout_table[] = {
{"root", "", "Root", "", W_ROOT, ""},
{"main_page", "root", "Weather Station","", W_PAGE, ""},
{"system_page", "root", "System", "", W_PAGE, ""},
{"sensor_card", "main_page", "Live Sensors", "", W_CARD, "width:400"},
{"w_temp_a", "sensor_card","Temperature A", "temp_a", W_DIAL, "min:0,max:120"},
{"help_temp", "sensor_card","", "", W_HELP, ""},
{"w_humidity_a", "sensor_card","Humidity A", "humidity_a",W_BAR, "min:0,max:100"},
{"help_humidity","sensor_card","", "", W_HELP, ""},
{"sensor_info", "sensor_card","", "", W_HTML, ""},
{"control_card", "main_page", "Controls", "", W_CARD, ""},
{"w_target", "control_card","Target Moisture","moisture_target", W_SLIDER, ""},
{"status_card", "system_page","Status", "", W_CARD, ""},
{"w_cpu", "status_card","CPU Temp", "cpu_temp_f", W_TEXT, ""},
};
const int layout_count = sizeof(layout_table) / sizeof(LayoutNode);The registry — app_register_items() — is unchanged.
Adding that entire UI required zero modifications to the framework.
Memory Footprint
The complete platform — dual-core AMP, lock-free FIFO sync, dynamic multi-page UI engine, captive portal, mDNS discovery, flash config, Home Assistant MQTT bridge — leaves 62% of RAM free for the user’s application code.
For context: the Pico W has 264KB of SRAM. The framework consumes approximately 100KB, leaving 164KB for sensors, state machines, and application logic. Most commercial IoT platforms require significantly more capable hardware and cloud infrastructure to deliver fewer features.
The layout table lives in Flash. The resolved table is a fixed 64-element array of small structs. The help strings live in Flash. The renderer never allocates. The JS is generated once per page request from Flash-resident string literals. The RAM cost of the entire UI system is the resolved table — approximately 64 × 80 bytes = 5KB.
What This Means
Before today, this was firmware with a web interface bolted on. The developer had to understand the framework internals to change the UI.
After today, the framework is a platform. The developer writes three
tables in their .ino file:
- What sensors and controls exist
- How they should be laid out on the website
- What documentation should accompany them
The framework handles everything else — routing, HTML generation, CSS, JavaScript, data binding, multi-page navigation, widget rendering. The two concerns are completely separated. The C++ describes structure and data. The framework generates the website.
That separation is what makes this a platform rather than a project.
No comments:
Post a Comment