Mastodon Politics, Power, and Science

Saturday, March 21, 2026

BluePrint IoT Framework — Configuration Manual

How to add a sensor, display it on a web page, and document it


Overview

Everything a developer touches lives in a single .ino file. The framework is a black box — you never modify it. Your job is to populate three tables:

Table What it defines Where
app_register_items() What the device knows — sensors, controls, callbacks Your .ino file
layout_table[] What the website looks like — pages, cards, widgets Your .ino file
help_table[] What the website explains — hover tooltips, inline text Your .ino file

This manual walks through adding a humidity sensor from scratch, using the actual humidity_a sensor from the Weather Station project as the working example.


Step 1 — Write the Hardware Callback

The callback is the function that reads your hardware and pushes the value into the registry. It is called by the scheduler on Core 1 on a repeating timer.

The signature is always the same:

int myCallback(struct _task_entry_type* task, int idx, int) {
    // reschedule yourself for next reading
    AddTaskMilli(task, registry.getItem_id(idx)->update_interval_ms, &myCallback, idx, 0);

    // read the hardware
    float value = /* your sensor reading here */;

    // sanity check — discard garbage readings
    if (value < 0.3) return 0;

    // push value into the registry
    registry.set_id(idx, value);

    return 0;
}

The idx parameter is the numeric registry index of this sensor — the framework passes it in automatically. You never hardcode an index. registry.getItem_id(idx)->update_interval_ms reads the polling interval you set in the registry, so the interval is defined in one place only.

The actual humidity callback from the Weather Station:

int readAM2302a_humidity(struct _task_entry_type* task, int idx, int) {
    AddTaskMilli(task, registry.getItem_id(idx)->update_interval_ms, &readAM2302a_humidity, idx, 0);
    
    am2302a.read();
    float humidity = am2302a.get_Humidity();
    if (humidity < 0.3) return 0;
    registry.set_id(idx, humidity);

    return 0;
}

Step 2 — Register the Sensor in the Registry

Inside app_register_items(), add one macro call. This is the only place you define what the sensor is — its ID, display name, polling interval, unit, and callback.

For a sensor that polls automatically on a timer:

SENSOR_AUTO(ID, NAME, INTERVAL_MS, UNIT, CALLBACK)
Parameter Description
ID Unique string identifier — used everywhere to reference this sensor
NAME Human-readable name shown in the web UI
INTERVAL_MS How often to poll in milliseconds
UNIT Unit string shown next to the value
CALLBACK The function pointer for the read callback

The humidity sensor registration:

SENSOR_AUTO("humidity_a", "Humidity A", 7003, "%", readAM2302a_humidity);

The interval is 7003ms — a prime-ish number deliberately offset from other sensors so they don’t all fire at the same moment and stack up on Core 1.

For a sensor updated manually by another callback (computed values like heat index):

SENSOR_MANUAL("heat_index", "Heat Index", "°F");

No interval, no callback — another function calls registry.set("heat_index", value) when it has a new reading.

For controls:

CONTROL_SLIDER("moisture_target", "Target Moisture", 40, 20, 80, 5, "%");
//              ID                 NAME               DEFAULT MIN MAX STEP UNIT

CONTROL_BUTTON("water_now", "Manual Water");
//              ID           NAME

The registry ID is the critical link between all three tables. Get it right here and everything else follows.


Step 3 — Add the Sensor to the Layout Table

The layout table defines your website. It is a flat array of LayoutNode structs. Every node has a parent — this implicit tree defines pages, cards, and what goes in them.

struct LayoutNode {
    const char* id;           // unique id for this UI node
    const char* parent_id;    // id of the parent node ("" for root children)
    const char* name;         // display name shown in the UI
    const char* registry_id;  // registry item this widget displays ("" for containers)
    WidgetType  widget;        // how to render this node
    const char* props;         // optional key:value properties ("" for none)
};

3a — Declare the tree structure first

Every layout starts with a root, then pages, then cards. These are containers — they have no registry_id.

const LayoutNode layout_table[] = {
    {"root",        "",         "Root",           "", W_ROOT, ""},
    {"main_page",   "root",     "Weather Station","", W_PAGE, ""},
    {"sensor_card", "main_page","Live Sensors",   "", W_CARD, ""},
    // ...
};

3b — Add a widget for your sensor

Inside the card, add a leaf node that references your registry ID:

{"w_humidity_a", "sensor_card", "Humidity A", "humidity_a", W_BAR, "min:0,max:100"},
Field Value Meaning
id "w_humidity_a" Unique UI node ID
parent_id "sensor_card" Which card this appears in
name "Humidity A" Not used for leaf widgets — display name comes from the registry
registry_id "humidity_a" Must match the ID from Step 2 exactly
widget W_BAR Render as a horizontal progress bar
props "min:0,max:100" Scale the bar from 0% to 100%

Widget types

Type What it renders Props
W_TEXT Plain numeric value with label and unit none
W_BAR Horizontal progress bar min:N,max:N
W_DIAL SVG arc gauge min:N,max:N
W_SLIDER Range slider control none (uses registry min/max/step)
W_BUTTON Momentary trigger button none
W_HELP ⓘ icon — hover to show tooltip none
W_HTML Inline static HTML none

Container types

Type What it renders
W_ROOT Invisible tree root — always first, always one
W_PAGE Top-level page — gets its own URL and nav tab
W_CARD Plain card — children stacked vertically
W_COLLAPSIBLE Card with a show/hide toggle header
W_TABBED Child cards rendered as tabs
W_RADIO Child buttons are mutually exclusive

Props reference

Key Applies to Effect
min:N W_BAR, W_DIAL Sets the zero end of the scale
max:N W_BAR, W_DIAL Sets the full end of the scale
width:N W_CARD Sets card width in pixels
momentary:true W_BUTTON Button resets to 0 after trigger

Declaration order matters

The renderer walks the table top to bottom within each parent. Nodes appear on screen in the order they are declared. Put the W_HELP node immediately after the widget it documents — the renderer checks for this and renders the ⓘ icon inline on the same line as the value.

{"w_humidity_a",  "sensor_card", "", "humidity_a", W_BAR,  "min:0,max:100"},
{"help_humidity", "sensor_card", "", "",           W_HELP, ""},

Step 4 — Add Documentation in the Help Table

The help table holds static HTML strings. They live in Flash — never in RAM — and are streamed to the browser on demand.

struct HelpNode {
    const char* id;    // must match the id of the W_HELP or W_HTML node in layout_table
    const char* html;  // HTML content — any valid HTML, no size limit
};

Add an entry whose id matches the W_HELP node id you declared in the layout table:

const HelpNode help_table[] = {
    {"help_humidity",
     "<b>Humidity A</b><br>"
     "AM2302 sensor on GPIO 2.<br>"
     "Range: 0&ndash;100% RH.<br>"
     "Updates every ~7 seconds."},
};
const int help_count = sizeof(help_table) / sizeof(HelpNode);

The id field "help_humidity" matches the W_HELP node id declared in the layout table. That is the only connection between them.

You can use any HTML inside the string — bold, line breaks, lists, inline styles. The tooltip renders inside a positioned div with the framework’s dark theme CSS already applied.

Inline HTML with W_HTML

For descriptive text that appears directly in the card rather than behind a hover, use W_HTML instead:

// In layout_table:
{"sensor_card_info", "sensor_card", "", "", W_HTML, ""},

// In help_table:
{"sensor_card_info",
 "<p><strong>Outdoor Conditions</strong></p>"
 "<p>Live readings from the <strong>AM2302</strong> sensor on GPIO 2. "
 "Temperature and humidity are polled every 6&ndash;7 seconds.</p>"
 "<p style='color:#888;font-size:.85em'>Range: -40 to 80&deg;C &bull; 0&ndash;100% RH</p>"},

Place the W_HTML node at the bottom of the card’s children to have it appear below the sensor readings.


Complete Example

Here is the complete definition for the humidity sensor across all three tables:

app_register_items()

SENSOR_AUTO("humidity_a", "Humidity A", 7003, "%", readAM2302a_humidity);

layout_table[]

{"sensor_card",   "main_page",  "Live Sensors", "",           W_CARD, ""},
{"w_humidity_a",  "sensor_card","Humidity A",   "humidity_a", W_BAR,  "min:0,max:100"},
{"help_humidity", "sensor_card","",             "",           W_HELP, ""},

help_table[]

{"help_humidity",
 "<b>Humidity A</b><br>AM2302 sensor on GPIO 2.<br>"
 "Range: 0&ndash;100% RH.<br>Updates every ~7 seconds."},

That is the complete definition of a sensor that reads hardware on a 7-second timer, displays as a scaled bar graph, and shows a hover tooltip with technical details. The framework handles all HTML generation, CSS, JavaScript data binding, and live updates automatically.


What Happens at Boot

When the device starts, it runs through a resolution phase before the web server starts:

  1. registry.begin() calls app_register_items() and stores all sensors and controls in the registry array.
  2. setupLayoutResolution() walks layout_table[] and resolves every registry_id string to a numeric array index. This is the only moment string lookups happen. All runtime access is O(1) array indexing.
  3. If a registry_id in the layout table doesn’t match any registry entry, the error is logged immediately to the serial port with the node id and the bad string. The device boots anyway — the broken node is skipped.
  4. Page endpoints are registered dynamically — one URL per W_PAGE node found in the layout table.

You will see output like this on the serial monitor during boot:

>> Registry Begin: Item Count: 10
>> [Layout] Boot resolution starting...
>> [Layout] Total nodes to resolve: 22
>> [Layout] node[0] id='root' parent='(root)' widget=0 container=YES
>> [Layout] node[6] id='w_humidity_a' parent='sensor_card' widget=8 container=NO
>> [Layout OK]    node 'w_humidity_a' -> registry[1] id='humidity_a' name='Humidity A'
>> [Layout] Boot resolution complete. 22 nodes resolved.
>> Registered page endpoint: /main_page
>> Registered page endpoint: /system_page

If you see [Layout ERROR] — check the registry_id in your layout node against the ID in your SENSOR_AUTO or SENSOR_MANUAL macro. They must match exactly, including case.


Quick Reference

To add a new sensor:

  1. Write the callback function
  2. Add SENSOR_AUTO(...) in app_register_items()
  3. Add a widget node in layout_table[] with registry_id matching step 2
  4. Optionally add a W_HELP node immediately after it in layout_table[]
  5. Optionally add a matching entry in help_table[]

To move a sensor to a different page or card:

Change the parent_id in its layout node. Nothing else changes.

To change how a sensor is displayed:

Change the widget type in its layout node. Add or modify props for scaling.

To add a new page:

Add a W_PAGE node as a child of "root". Add cards as children of that page. The framework registers the URL and adds the nav tab automatically.

To add a new card to an existing page:

Add a W_CARD node with the page as its parent_id. Add widget nodes as children of the card.

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...