No description
  • C++ 96.6%
  • CMake 2.5%
  • C 0.9%
Find a file
2026-10-03 21:14:09 +02:00
include/jt jt-device: WifiPower (radio awake for updates, pages, streams, commands, external power, weak signal), the updater holds it; release script for git.jotronics.de 2026-10-03 21:14:09 +02:00
src jt-device: WifiPower (radio awake for updates, pages, streams, commands, external power, weak signal), the updater holds it; release script for git.jotronics.de 2026-10-03 21:14:09 +02:00
test jt-device: WifiPower (radio awake for updates, pages, streams, commands, external power, weak signal), the updater holds it; release script for git.jotronics.de 2026-10-03 21:14:09 +02:00
CMakeLists.txt jt-device: WifiPower (radio awake for updates, pages, streams, commands, external power, weak signal), the updater holds it; release script for git.jotronics.de 2026-10-03 21:14:09 +02:00
idf_component.yml jt-device: release as its own repository (lab/jt-device), component manifest 2026-09-30 12:35:28 +02:00
README.md jt-device: WifiPower (radio awake for updates, pages, streams, commands, external power, weak signal), the updater holds it; release script for git.jotronics.de 2026-10-03 21:14:09 +02:00

jt-device

The platform's device-side C++: what every firmware (cam, heater, mower) needs to take part in the platform, built on nats-cpp.

  • jt::Guard (include/jt/guard.hpp): signed commands with capability tokens. A device checks do and admin commands with only its tenant's signing keys: the token (trusted issuer, expiry, delegation), the holder's signature over subject, time, nonce and payload, replays, revocation, and the command's class on this device (docs/NATS-CONCEPTS.md §6, docs/SUBJECTS.md).

  • jt::verifyRelease (include/jt/release.hpp): may a desired version be installed? Signed by a trusted release key, and the statement names this project, version and file (SHA-256, size); the same codes as the platform's Desired.VerifyFor.

  • jt::UpdateAgent, jt::TwinUpdater (include/jt/twin_updater.hpp, ESP-IDF only): firmware updates of protocol v1. After connecting:

    auto agent = std::make_unique<jt::UpdateAgent>(nc, jt::UpdateConfig{
        id, "scale", esp_app_get_description()->version, jt::parseKeyList(JT_RELEASE_KEYS)});
    agent->start();          // then agent->step() about once a second, from the app's task
    

    It finds the twin bucket (retried), reports the boot (ok or rolled_back), watches dev.<id>.desired, checks, downloads, installs and reboots; jt::imageConfirmed() when the app confirms the image. Used by the cam and the espresso scale.

    • UpdateConfig::onProgress(const UpdateProgress&): state (downloading 0–100 %, installing, failed), the version and the error; for devices with a display. Called from the update task, so hand it to the UI task instead of drawing there.
    • Restart-loop guard: the version being installed is marked in NVS (twin/busy). If the device restarts before the update ended, the boot report is failed ("the device restarted while installing X") and X is not tried again until another version is wanted.
    • The update task (jt::kUpdateStack, 12 KB; a real download left 1.1–1.5 KB of 10 KB) runs on internal RAM: installing maps the image, and ESP-IDF asserts on a PSRAM stack while the cache is frozen.
    • jt::reserveUpdateWorker(): call early in app_main to take the update task's stack while internal RAM is still in one piece; updates then run on it (the scale: 20 KB free after connecting, but no 10 KB block). It holds 12 KB for good: not for devices as tight as the Gimbal cam (16 KB free). UpdateConfig::runUpdate runs jobs on a task of the app's own instead.
    • No memory for the update task twice in a row: the device restarts once per version to install right after boot, when the RAM is in one piece (NVS twin/nomem prevents a restart loop).
  • jt::imageCheckStart, jt::imageHealthy (include/jt/image.hpp, ESP-IDF): when a new image counts as good. With a platform once it has the boot report (the update agent calls imageHealthy()); not within 10 min although the network is up → restart into the previous image; without network confirmed anyway; standalone after 60 s. Needs the bootloader's app rollback, harmless without it. The confirm runs on the esp_timer task (reading otadata maps flash, which asserts on a PSRAM stack); a boot report that arrives before imageCheckStart still counts.

  • jt::resetReason, jt::lastCrash (include/jt/crash.hpp, ESP-IDF): why the device started and what crashed before; the boot report sends both (reported.reset, reported.crash). The panic handler is wrapped (linker --wrap=esp_panic_handler, set by this component) and copies the reason in IRAM into RTC memory, with up to 10 backtrace addresses: with silent asserts every abort's pc is panic_abort, the callers say where. Decode them with the ELF of exactly that version (CI keeps it: ~/.cache/jotronics/elf/<project>/).

  • jt::Presence, jt::chipTemp, jt::addSystemStatus (include/jt/presence.hpp, ESP-IDF): dev.<id>.online at every (re)connect and dev.<id>.tel.status every n seconds, with the system fields (version, uptime, reset, chipTemp, wifi.rssi, memory.internalKB/largestKB/psramKB) and the app's own; step(nc) about once a second from the app's task. jt::chipTemp() owns the temperature sensor (it can be installed only once). Used by the scale and the cams (protocol v1).

Later: encrypted payloads (xkeys).

Tests

test/vectors.json is made by the platform's Go guard (go/internal/natsops, TestDeviceVectors); the C++ guard must give the same code for every vector, so both implementations stay the same. test/release_vectors.json does the same for releases (go/internal/platform, TestReleaseVectors, build/jt_release_tests).

cmake -S . -B build -DNATS_CPP_DIR=<nats-cpp checkout>
cmake --build build && build/jt_device_tests

# new vectors after a change of the Go side (commit both)
cd ../../go && JT_WRITE_VECTORS=1 go test ./internal/natsops -run TestDeviceVectors
cd ../../go && JT_WRITE_VECTORS=1 go test ./internal/platform -run TestReleaseVectors

On NixOS: nix-shell -p cmake mbedtls gcc gnumake --run '…'. Not yet a Nix check: nats-cpp is not reachable from the flake (see docs/PLAN.md, housekeeping).

In a firmware: an ESP-IDF component (REQUIRES nats-cpp); copy the headers of a command into jt::CommandHeaders and pass the device's clock (SNTP) as now_ms.

Releases

jt-device is developed here, next to the Go code its test vectors come from, and released as its own repository lab/jt-device on the lab Forgejo (history included):

scripts/jt-device-release.sh v0.2.0     # commit first; pushes main and the tag

Firmware pins the tag (and nats-cpp's, lab/nats-cpp) in main/idf_component.yml:

jt-device:
  git: http://localhost:13000/lab/jt-device.git
  version: v0.2.0

Renovate (lab VM, nix/modules/renovate.nix) opens one PR per firmware repository for new library tags; CI builds it (size budget included, nothing is published from a branch) and Renovate merges it when green, which publishes to the dev channel. To try an unreleased change in a firmware, build it with JT_LOCAL_COMPONENTS pointing at this directory (and at a nats-cpp checkout).

Versions: patch for fixes, minor for new API (old firmware still builds), major when a firmware has to change.

WiFi power (jt/wifi_power.hpp, C: jt/wifi_power.h)

When the radio may use modem sleep. It stays awake while something needs it (hold()/release() counted, holdFor(ms)), on external power on battery devices, and on mains devices while the signal is weak (below -75 dBm, back after 60 s at -70 dBm or better); otherwise the device's own setting applies. The twin updater holds it during every download and install.

jt::WifiPowerConfig c;  c.powerSave = settingOn;  c.batteryDevice = false;
jt::WifiPower::instance().start(c);          // after esp_wifi_start()
jt::WifiPower::instance().hold();            // stream started / page opened ...
jt::WifiPower::instance().holdFor(60000);    // ... a command arrived

The rule alone is jt::WifiPowerPolicy (host test test/test_wifi_power.cpp).