Firmware monorepo for a distributed home-IoT system: ESP8266/ESP32 nodes talk to a Mosquitto broker over MQTT/TLS (with optional Home Assistant auto-discovery), and an ESP32 gateway bridges a 500 kbit/s CAN bus of ATmega328P devices onto MQTT. Everything — firmware, configuration files, even the CAN devices' firmware — is updatable over the air through MQTT.
About this repository. This is a personal project built for my own home setup, shared as a reference to draw ideas from rather than a turnkey product to deploy as-is — the hardware, CAN IDs and device list are specific to my installation. If something here is useful to you, take the pattern and adapt it. Things it may be worth a look for: a CAN↔MQTT bridge, an MQTT-based OTA scheme that also updates the AVR CAN nodes through the ESP32 gateway, a native (host) test setup with hardware shims for embedded code, and a one-command release gate (build + tests + static analysis + lint/type/format) mirrored in CI.
MQTT over TLS (8883)
Server side ───────────────────────── Device side
┌──────────────────┐ ┌─────────────────────────┐
│ Mosquitto broker │◄────────────────►│ ESP8266 nodes │
│ Home Assistant │ │ • rad (ENC28J60 LAN)│
│ ota/otaUpdate.py │ │ • thermo (Wi-Fi) │
└──────────────────┘ ├─────────────────────────┤
│ ESP32 CAN gateway │
│ (LAN8720 Ethernet) │
└───────────┬─────────────┘
CAN bus │ 500 kbit/s, ext. ID
┌───────────┴─────────────┐
│ ATmega328P devices │
│ • alert (LED+MP3+sens.)│
│ • irrigation (pumps) │
└─────────────────────────┘
| Environment | MCU | Role |
|---|---|---|
nanoatmega328_alert |
ATmega328P | CAN alert node: WS2812 strip, DFPlayer MP3, Si7021 + LDR, pushbutton |
nanoatmega328_irrigation |
ATmega328P | CAN irrigation node: 4 pump channels, flow/current safety checks, moisture sensors (CAN-only — see below) |
project_esp8266_rad |
ESP8266 | Geiger counter (CPM → µSv/h) + 433 MHz RF transceiver, ENC28J60 Ethernet |
project_esp8266_thermo |
ESP8266 | DS18B20 multi-probe thermometer (works with zero probes — handy as a test board) |
project_esp32_can |
ESP32 | CAN↔MQTT gateway for the alert nodes, LAN8720 Ethernet |
native_test |
host | Native unit-test suite (custom runner + shims) |
The irrigation node is CAN-only for now: it publishes MOISTURE_DATA and
IRRIGATION_ERROR on the bus, but the gateway has no driver registered for its CAN ID, so
those frames are dropped and nothing reaches MQTT or Home Assistant. The node is dormant and a
CanIrrigationDriver is future work; CanMqttGateway already carries the shared parts
(availability, info, OTA, discovery), so only processCanFrameArrived() would need writing.
The nanoatmega328_bootloader_* environments only burn the urboot bootloader and fuses
(see bootloader/README.md for the variants, fuses and rebuild steps).
-
Device → server:
iot/dtos/<mac>/<subtopic>; server → device:iot/stod/<mac>/<subtopic>. -
Every node publishes a retained
availabilitytopic (LWT) and a retainedinfotopic (fw version = git commit count, git hash, dirty flag, reset reason, andboot: how far the previous run's startup got as aBootStageordinal —0when nothing was recorded,12once the main loop had the device). -
The info topic's
rris the SDK's own enum on the ESP nodes. On the CAN nodes it is the bitmaskResetHandler::getResetReason()builds — MCUSR in bits 0-3, a deliberate-restart flag in bit 4, and why in bits 5-7. Read it back as three fields rather than looking a value up:Bits What they carry 0-3 MCUSR as the hardware left it. 0x05is a power-on (brown-out comes up with it),0x04a brown-out while running — the urboot envs set BOD to 4.3 V — and0x08a reset pin or a hang the watchdog caught: urboot consumes EXTRF, so the two look alike. A wholerrof0x00means the node is not running urboot, the only bootloader that hands MCUSR on.4 Set when the reset came from restartMCU(). Those always arrive through the watchdog, so bit 3 is set alongside.5-7 The ResetHandler::RestartCauseordinal, meaningful only while bit 4 is set. The enum is the list; a new cause needs no change here. -
After every reconnect the node publishes a retained
diagtopic: the cause of the last disconnect (MQTT status orNETWORK_LOST), its UTC timestamp, the offline duration in seconds (measured from client-side detection, i.e. up to ~2x keepalive after the actual drop), a since-boot reconnect counter, andpingRetry, the keep-alive pings the TCP client refused to take since boot — those are retried rather than dropped, so the count is what tells a keep-alive loss caused here apart from one the network caused. Kept in RAM only: an outage that ends in the offline-watchdog MCU reset is reported by theinfotopic's reset reason instead. -
CAN sub-devices get their own sub-tree:
iot/dtos/<mac>/alert1/{availability,info,ota,button}. An alert node takes{"Colors":[r,g,b]}on itsiot/stodtopic to set the LEDs, and{"Sound":n,"Volume":v}to play a track - withColorsif it should light up while playing. -
The gateway hands out CAN addresses on its own
cansubtopic. A node with nothing in EEPROM answers on an address it derives from its unique id (0x300 | (crc16(uid) & 0xFF)) and announces itself there every 5 seconds; the gateway keeps what it hears for 30 seconds. Both answers go to the subtopic the question arrived on:{"list":true} -> {"waiting":[{"uid":"a1b2c3d4e5f60708","at":789}]} {"assign":{"uid":"a1b2c3d4e5f60708","id":26}} -> {"type":1,"cmd":9,"err":0} (ACK)assignrefuses anidof 0, the master's, one a driver already answers on, or one from the provisional block; the node itself refuses a request that did not come from the master or that names an address it no longer holds. On success it stores the pair and restarts. -
Home Assistant MQTT discovery is opt-in via
"haDiscovery": trueinserver.json; when disabled, the nodes actively retract their previously published entities.
| File | Purpose |
|---|---|
/config/server.json |
Wi-Fi + MQTT credentials, server URL/port, haDiscovery toggle |
/config/mosq-ca.crt |
CA certificate for TLS server validation (NTP sync runs first for X.509) |
/config/tube.json |
Geiger tube type (rad node only) |
server.json is rendered per device by ota/otaUpdate.py from ota/secrets.yaml
(git-ignored secrets) + ota/devices.yaml (non-secret fields); the first LittleFS image of a
fresh device is flashed over USB by the tool's Initial provisioning action, which stages
the device's /config/* files into the transient, git-ignored data/ directory and runs
uploadfs. See ota/README.md.
ESP firmware / files (MQTT): ota/otaUpdate.py (interactive curses menu; configured by
ota/secrets.yaml + ota/devices.yaml) sends files as base64 pieces with per-piece ACK.
See ota/README.md for setup and a copy-paste secrets.yaml example.
A firmware upload must carry a binId, which the running firmware checks against its own PIO env
before accepting the transfer; from there it streams into the Updater and reboots. Other files go
to a temp file, are MD5-verified and renamed into place (file names are allow-listed). The
100-byte piece size is deliberate — larger pieces measured slower in practice.
CAN device firmware (two-stage): the ATmega firmware is first uploaded to the gateway's
LittleFS as /canAlertFw.bin (this also auto-triggers the CAN OTA), then streamed over the CAN
bus one OtaCanFrame::dataPieceSize chunk per frame — the eighth data byte carries the piece's
place in the stream — checked against a CRC16 over the whole image. The ATmega stages it to its
SPI flash (W25Q64); on reset the urboot dual-boot bootloader programs the MCU from SPI
flash. Result: {"OTA":"[OK]"} / {"OTA":"[ERR]"} on the device's ota subtopic.
Every node expecting that file is updated from the one upload, one transfer at a time: they share a CAN bus and a controller that holds a single frame, so running them together finishes no sooner and keeps each node in transfer twice as long. A node that is not answering its ping is skipped rather than queued, and the image's checksum is computed once for the whole batch.
| Path | Contents |
|---|---|
src/main_*.cpp |
One entry point per environment (selected via build_src_filter) |
lib/ |
Feature libraries (task scheduler, MQTT/CAN stacks, drivers, OTA, …) |
test/ |
Native test suites + test/_shims/ (Arduino/LittleFS/Update/PubSubClient fakes) |
ota/ |
Server-side OTA/file-transfer tool + its own README (runtime deps in ota/requirements.txt: paho-mqtt, pyyaml, tqdm) |
scripts/ |
Build helpers (git version injection, ELF→BIN, library patching, size compare) and the release gate (release_check.py) |
bootloader/ |
Prebuilt urboot images for the ATmega nodes |
data/ |
LittleFS image source — transient and git-ignored; ota/otaUpdate.py provisioning generates and clears it |
audio/ |
MP3 set for the alert node's DFPlayer SD card (see audio/README.md) |
pio run # build all environments
pio run -e project_esp8266_thermo -t upload # serial flash one target
pio test -e native_test # native test suite
pio check # cppcheck on all environments
python scripts/analysis_check.py # clang-tidy on the check_* environmentsA fresh device is set up entirely from ota/otaUpdate.py: the Initial firmware flash
action serial-flashes the firmware, and Initial provisioning flashes the first LittleFS
config image (it generates data/ and runs uploadfs itself — see
ota/README.md).
Watching a board on the bench goes through scripts/board_console.py, which restarts it over the
USB-serial adapter's control lines and prints what it says next:
python scripts/board_console.py --board esp # ESP8266 / ESP32
python scripts/board_console.py --board avr --listen 20 # ATmega328PThe wiring has to be named because nothing in the USB descriptors reveals it — the same CH340 sits under a D1 mini and under a Nano — and the two differ in a way that matters: an ESP board restarts from RTS and is left alone by merely attaching, while an ATmega restarts from DTR, which opening the port already asserts, so there is no way to watch one without restarting it first. The script's own docstring carries the rest, including what each board then reports about the restart.
The whole build is warning-clean under -Wall -Wextra -Werror; keep it that way.
Firmware version comes from the git commit count, so commit before flashing release builds
(the dirty flag is published in the info topic).
C/C++ builds and tests run through PlatformIO. Setting up a fresh clone:
git clone <repo> && cd CanSystems
git config core.autocrlf input # Windows only: keep LF endings (the gate checks them)
python -m venv .venv # release-gate Python tooling + OTA runtime deps
.venv/bin/pip install -r requirements-dev.txt- PlatformIO provides the build (
pio). Install it via the VS Code pioarduino IDE extension (recommended in.vscode/extensions.json) orpip install pioarduino; it runs from its own install (~/.platformio/penv/bin/pio), not from.venv, and the root.venvdoes not interfere with it. Toolchains download on the first build (needs internet). - The Python tooling for the release gate (clang-format, ruff, pyright, pytest, gcovr) plus the
OTA tool's runtime deps are pinned in
requirements-dev.txt; installed into the project-root.venv, every gate guard discovers it automatically. CI installs the same file pluspioarduinoandintelhexon top. - Remotes: the canonical repo is the self-hosted Gitea, which push-mirrors to GitHub
automatically — a single
origin(the Gitea URL) is all a working clone needs. A clone from GitHub works too; it just cannot push. - Per-deployment files are not in the repo (git-ignored):
ota/secrets.yamlholds all broker and device credentials in one file — copy it from your other machine or recreate it from the template inota/README.md. The CA bundle (ota/mosq-ca.crt) regenerates automatically from the system trust store. Both are only needed to run OTA or to provision a device; building and testing the firmware needs neither.
scripts/release_check.py chains nine steps fail-fast: check that the pinned tooling is the
tooling installed (deps_check.py — in the project .venv, or in the interpreter it runs under
when there is none), build all environments, run the native test suite, static
analysis (pio check — where a single defect of any severity via
--fail-on-defect low/medium/high fails the gate, unlike a bare pio check, which reports
SUCCESS even with findings) in two steps — cppcheck over every environment, then clang-tidy over
the check_* ones, which are the same builds with the framework include paths clang-tidy needs
to parse anything — then the Python guards — clang-format + final-newline
(format_check.py), ruff lint (lint_check.py), pyright strict (typecheck_check.py), and
pytest (pytest_check.py). Every guard is required: a missing tool fails the gate rather
than skipping, so set up the dev venv (above) before running it. The dependency check comes
first because every guard after it runs out of the .venv: one that has drifted from the pins
reports on versions the project does not pin, and the run still passes. Step 0 checks the git
tree with the same rule the firmware uses for its GIT_DIRTY flag.
python3 scripts/release_check.py # build + test + check; dirty tree is a warning
python3 scripts/release_check.py --strict # release mode: a dirty tree fails immediately
python3 scripts/release_check.py --sync # install the pinned tooling into .venv firstEach command streams its output unchanged (through a pseudo-terminal, colors included), so a
human sees exactly what standalone runs would print. The run ends machine-friendly: a summary
table, the last 50 lines of the failed step (ANSI-stripped), and a final
RELEASE CHECK: PASS|FAIL (step: <name>) marker line — tail is enough to know everything.
Exit code is 0 only on a fully clean run (~5 minutes).
- Cross-project reflash: a running firmware only accepts an OTA image that names its own environment, so converting a board to another project needs a one-time serial flash.
- CAN IDs are stored in EEPROM (CRC-protected). A node with none announces itself and is
given one over the gateway's
cansubtopic (see "MQTT scheme"). Building once withNEW_CAN_ADDRESSdefined inplatformio.ini(master ID isMASTER_CAN_ADDRESS=10) and then removing it again writes the pair directly — that is the route for a node that already has an address, since only a node still waiting for one is on the commissioning list.