The platform includes an ESP32-S3 firmware framework, a MAC-keyed device registry and an over-the-air firmware path addressed by device identity rather than by IP address. Telemetry from physical hardware arrives on the same broker and the same topic namespace as the rest of the system. This is engineering work done with you, not a product you download and switch on.
At the edge the expensive question is rarely which model to use. It is whether the data reaches a model at all, and what it took to get there. A site with intermittent uplink, a radio link and no operator on the premises is an integration project before it is an AI project.
The managed alternatives are region-scoped, not premises-scoped. An inference endpoint in the nearest cloud region is not the same thing as one in the building, and none of the routing or serving vendors sell a device layer at all — that part of the problem is left to you and a separate IoT platform. Here the device layer, the event bus, the service fleet and the inference path are one system with one operational surface. That is worth something specific: telemetry is queryable next to service state without an integration between two vendors' data models.
Heltec WiFi LoRa 32 V3 and V4, both ESP32-S3. V4 adds native USB CDC, PSRAM and GPS; its PA front-end pins are reserved. The build targets are separate and never mixed.
firmware/mx — heltec_v3 · heltec_v4
A hardware abstraction layer, a transport layer, a message framework and application managers, over FreeRTOS tasks. Managers are singletons; the main loop stays empty and work is timer, ISR or event driven.
lib/HAL · lib/Transport · lib/Mx · lib/App
A two-tier heartbeat runs at 2 s when something is happening and 5 min when nothing is. It is kicked by real events — a command, a relay change, an HTTP hit — never by a periodic tick asking whether anything changed.
lib/App/heartbeat.cpp
Every node below is a real module. The paths under them are real.
Publishes battery, RSSI, heap and uptime on its own topic. Offline status is an MQTT last-will, so a dead device announces itself.
lib/Transport/mqtt_transport.cpp
Mosquitto or EMQX, on your network. Third-party and replaceable — the platform holds a topic contract, not a broker dependency.
Subscribes the device topics and owns the whole path: subscribe, persist, serve. No ingest agent, no external time-series database.
daemon/src/telemetry_store.py
Raw device fields are mapped to canonical metric names and each carries its own unit label, so a chart does not have to hardcode which field is volts.
battery_mv → battery_voltage_v
SQLite in write-ahead mode on your disk. One row per field per sample, indexed by device and by time.
KEVIN_HOME/db — table telemetry
The same sample is republished on the local event bus, so live operator views are a second reader of one ingest — not a second ingest.
device:telemetry
Topic patterns are generic subjects; domain meaning lives in the subscriber's config.
| Topic | Direction |
|---|---|
| kv/{node}/telemetry | device → platform |
| kv/{node}/status | device → platform, as the last-will |
| kv/{node}/heartbeat | device → platform |
| kv/{node}/cmd | platform → device |
| kv/{node}/msg | device → platform, command ack |
| kv/class/{class} | platform → one hardware class |
| kv/broadcast/command | platform → fleet |
Delivery guarantees are set per subject, not globally: telemetry is fire-and-forget because a dropped sample is cheaper than a stalled radio, status and heartbeat are at-least-once, and commands are exactly-once.
daemon/src/mqtt_config.py · firmware/mx/lib/Transport/mqtt_transport.h
Battery voltage is reported by the firmware and acted on by nobody on the device. Mains-loss alerting is a rule in the daemon, where it can be changed without a reflash. The split is deliberate: firmware that makes policy decisions is firmware you have to reflash to change your mind, across every device, in the field.
A detector polls the registry every 30 s and marks devices whose heartbeat has gone stale past the configured timeout, publishing a fleet event when it does. An absent device is a state, not a gap in a chart.
fleet:device_offline
Hardcoded IP addresses are how fleets get bricked. A device is addressed by registry identity; the registry resolves what hardware it is and where it currently lives, and the hardware class it reports decides which build it is allowed to receive.
Names a device, never an address.
POST /api/ota/flash
{"device_id": "KV-XXXX"}
MAC-keyed. Holds hardware class, current firmware version, address and last-seen. Unknown device ID is a 404, not a guess.
daemon/src/device_registry.py
Hardware class selects the build environment. A V3 image cannot be sent to a V4 device, because nothing in the path lets you name the image directly.
daemon/src/ota_manager.py
| Step | Interface |
|---|---|
| Device boots and self-registers by MAC | POST /api/registry/register |
| Unknown MAC is held for an operator to approve | POST /api/registry/devices/{id}/approve |
| Flash one device by identity | POST /api/ota/flash |
| Or every online device of one hardware class | POST /api/ota/flash/by-class |
| Poll the job log while it runs | GET /api/ota/status/{job_id} |
| Read the version back off the device | GET /api/version |
Pull-style updates are supported: the daemon serves the compiled image and the device fetches it. A non-HTTPS update URL is rejected before any job is created, rather than failing somewhere inside a flash.
There is one rule that makes fleet updates auditable rather than hopeful: if a device reports the same version after an upload, the flash failed. Not "probably succeeded". Failed. Firmware version strings carry their hardware target, and the repository holds independent counters per target so a V3 and a V4 build can never be confused for one another.
0.0.64V4 · 0.0.64V3
One row per device, MAC as the durable key.
| device_id · friendly_name | Stable identity, human label. |
| mac_address | The key that survives a DHCP lease change. |
| hardware_class | Decides which build is legal for this device. |
| current_version | Compared against the readback after a flash. |
| ip_address | Current address. Derived, never authoritative. |
| status · last_seen | Online, offline, or awaiting approval. |
New devices are not trusted automatically. An unrecognised MAC self-registers into a pending state and stays there until an operator approves it. Automatic approval exists as a configuration flag for lab benches; it is not the default.
A device that only speaks IP is useless in a field, a basement or a trailer. The transport layer treats radio, serial and IP links as interchangeable carriers of the same messages, and a message router moves traffic between them.
Long-range sub-GHz radio, no infrastructure required. Packets are encrypted with AES-128-GCM through the platform's crypto library and deduplicated against a rolling-hash window on receive. RSSI and SNR are reported per packet.
lib/Transport/lora_transport.cpp
MAC-addressed WiFi-band messaging with no access point and no association. The peer registry persists across reboots in non-volatile storage, with a fixed peer ceiling.
lib/Transport/espnow_transport.cpp
A GATT terminal for a phone standing next to the device, and USB serial for the bench. Both carry the same command set as the network transports, so commissioning does not need a working uplink.
lib/Transport/ble_transport.cpp
The message router registers every active transport, polls them and dispatches packets between them. In practice that means one device with an uplink carries traffic for devices that have none: a radio message arrives on LoRa and leaves on MQTT, without either end knowing which link the other used.
lib/Transport/message_router.cpp
It is not a routed multi-hop mesh. Radio links are point to point, plus the transport bridging above. A gossip dissemination layer exists in the tree but its transmit path is a stub and it is not wired to a radio — so it is not a capability, and we will not describe it as one. If your topology needs multi-hop, that is design work in the engagement, not a feature you are buying.
Everything above is real and running, and none of it is a self-serve product. Two variants of one board are supported. Your sensors, your enclosure, your radio plan and your site are not in the tree yet. So the engagement is scoped as non-recurring engineering against a defined site, with the work landing in the platform rather than in a fork.
What is measured, what is actuated, what the uplink actually is on a bad day, and what has to keep working when it is gone. Written down before anything is built.
Every firmware feature gets a spec with observable pass or fail criteria — a serial pattern or an HTTP response — and no change is accepted until those cases run on the bench.
Registry, OTA path and telemetry running on your hardware, operated by your people. The deployment does not depend on us continuing to exist.
Two design partners at a time, priced as engineering. We do not forecast revenue from this in the first year, and you should not plan around us shipping a general-purpose device product on a schedule.
This is the deepest thing we have built and the slowest thing we sell. It is also the easiest to buy for the wrong reason. These are the cases where you should not.