Mastodon Politics, Power, and Science

Saturday, March 21, 2026

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:

  1. What sensors and controls exist
  2. How they should be laid out on the website
  3. 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

Measurement as Ratio: The Invariant Beyond Units and Constants

J. Rogers, SE Ohio This paper is at:  https://github.com/BuckRogers1965/Physics-Unit-Coordinate-System/tree/main/docs Abstract Measurement i...