Changelog¶
All notable changes to espOS. The format is Keep a Changelog; versions are semantic-ish as judged from a consumer firmware's point of view (docs/releasing.md) — espOS is pre-1.0, so a minor bump is where a consumer may have to change code, and such entries say so.
Entries name the component the way commit scopes do (wifi, sk, ble,
…); the PR number is the place to read the reasoning.
Sections are generated by release-please from the titles of merged pull
requests (docs/releasing.md); do not edit this file in a
pull request. The [Unreleased] block below was written by hand before the
switch; its entries belong to the next feature release and are folded into
that section when it is cut.
0.12.1 (2026-09-29)¶
Fixed¶
0.12.0 (2026-09-28)¶
⚠ BREAKING CHANGES¶
- health: warn on how little RAM was free, not only how much is free now (#147)
Added¶
- health: read the conditions back, and rehearse a fault (#149) (ea55514)
- health: warn on how little RAM was free, not only how much is free now (#147) (c4f56bb)
0.11.0 (2026-09-27)¶
⚠ BREAKING CHANGES¶
- wifi: do not raise the setup portal mid-association (#146)
Added¶
- ble: keep the advertisement ring out of internal RAM where PSRAM exists (#141) (1d17825)
- ble: let a firmware reserve the radio controller before the network (#134) (7e27551)
- sk: make the SignalK task stacks configurable, with measured numbers (#135) (95be441)
Fixed¶
- build: component checks that work when espOS is installed from the registry (#140) (8878485)
- sk: back off a duplicate access request instead of asking every minute (#130) (569b681)
- wifi: do not raise the setup portal mid-association (#146) (fe5030d)
- wifi: reconnect to the last known AP without rescanning every channel (#139) (800cbbf)
0.10.3 (2026-09-24)¶
Fixed¶
- ble: size the advertisement buffer to the heap, not to a constant (#126) (2e4e68a)
- ci: an expression in a description breaks the workflow for every caller (#123) (fe047e0)
- ci: make build-firmware.yml usable from a workflow_call (#121) (485a382)
- health: stop republishing a condition that has not changed (#125) (e8b09ab)
0.10.2 (2026-09-21)¶
Fixed¶
0.10.1 (2026-09-20)¶
Fixed¶
0.10.0 (2026-09-20)¶
Added¶
- httpd: say what the board is, from what can actually be known (#113) (a9dce2b)
- ui: a page for the flow graph, and for the two numbers that say it is in trouble (#111) (1d844eb)
0.9.1 (2026-09-19)¶
Fixed¶
0.9.0 (2026-09-19)¶
Added¶
Fixed¶
- build: name the espOS remote when it is not upstream (#104) (00dd2b7)
- core: build the registry example on esp32 too, and say why it needs a line (#107) (c9932b6)
0.8.1 (2026-09-19)¶
Fixed¶
- build: say which espOS a firmware was built against, honestly (#103) (27ac6d6)
- ota: watch the signing key on the registry path, and make the watch work (#100) (219496a)
- registry: ship the partition tables, and catch a table the chip cannot hold (#102) (e688643)
0.8.0 (2026-09-19)¶
Added¶
Fixed¶
- core: a radio that will not start is no longer fatal (#97) (814b72b)
- ota: the certificate bundle could not be turned off (#96) (f13c9a4)
- registry: make espOS installable from the component registry (#99) (460e85d)
0.7.1 (2026-09-14)¶
Fixed¶
Unreleased¶
Changed¶
-
partitions: the
nvspartition is 48K, was 24K, in all three shipped tables. A graph node keeps its settings there and a curve table is one string key of up to about 4 KB, so the release that introduced editable tables is the one that has to size the partition for them: the size is fixed when the table is flashed and growing it later needs a USB flash, not an OTA. Existing devices are not affected by an update — an OTA writes the app slot, not the partition table, so a device in the field keeps the layout it was flashed with, and espOS resolves every partition by label rather than offset. A device only takes the new table when it is flashed over USB, which erases NVS anyway. Consumers with their own partition CSV should make the same change; in both espOS consumers the three small data partitions afternvsshifted into space that was already spare beforeota_0, so no application or data partition moved. -
build: consumers inherit espOS's sdkconfig instead of copying it.
espos_project_prologue()assemblesSDKCONFIG_DEFAULTSfromsdkconfig.d/espos.defaults(+.<target>), an optional profile (PROFILE release|debugor-DESPOS_PROFILE=), the project'ssdkconfig.defaults, a git-ignoredsdkconfig.local, and a generated partition fragment. Consumers: delete the mirrored espOS lines from yoursdkconfig.defaultsand passPARTITIONS <csv>(defaultpartitions/4mb.csv;8mb.csvand16mb.csvare bundled and set the flash size) — the prologue's table wins over aCONFIG_PARTITION_TABLE_CUSTOM_FILENAMEin your defaults. Delete an existingbuild/sdkconfigonce after the bump: defaults only apply to a fresh sdkconfig, and a stale one still names the removed rootpartitions.csv. - build: consumer projects build with IDF's
MINIMAL_BUILD; espOS's own tree still compiles every component. Consumers: name the optional espOS components you use —espos_project_prologue(... COMPONENTS espos_ble)— the rest (espos_ble,espos_n2k,espos_voice,espos_audio) are excluded outright, because the component manager resolves every visible manifest before the build graph is trimmed andespos_voicealone drags esp-sr, esp-dl and esp-dsp into a headless gateway's lock (0 members linked).sdkconfig.defaults*moved tosdkconfig.d/espos.defaults*,partitions.csvtopartitions/4mb.csv. - build: IDF version policy —
.idf-versionis the tested release, any 6.0.x builds with one warning, versions outside[6.0.0, 6.1.0)are refused unless-DESPOS_ALLOW_IDF_MISMATCH=1. - ui:
ui/dist-gzis committed; firmware builds no longer need Node, a missing default bundle is a hard error, CI checks the bundle is fresh. - httpd, wifi, sk, ota:
*_start()fails withESP_ERR_INVALID_STATEand a log line naming the missing prerequisite when called out of order. - Boot log narrates version and target, portal instructions, connection and web UI URL, server found, access request, approval.
- The reference app in
main/is built onespos_start(); config is read once and on change. - sk: the
lowMemorycheck moved out of the SignalK health tick intoespos_health, so it exists without SignalK; the token legs and meta reconciliation share the new HTTP client.CONFIG_ESPOS_HEALTH_MAX_CONDITIONSdefault 8 → 12. - sk: discovery no longer starts the responder or sets the hostname (both
moved to
espos_wifi); a browse waits forespos_mdns_is_ready()(bounded), returns nothing without a link, and is compiled out withCONFIG_ESPOS_WIFI_MDNS=n(setsk.server_host). Theespressif/mdnsdependency moved fromespos_sktoespos_wifias a publicREQUIRES. - ble: advertisements are posted through
espos_sk_http_post()and the control WebSocket sends the token as anAuthorizationheader instead of?token=; both channels followsk.tls(https/wss) and the socket re-dials on a scheme change. - Relicensed from the source-available, no-redistribution license to
Apache-2.0:
LICENSE,NOTICE, SPDX headers on every source file,REUSE.tomlfor the rest,THIRD-PARTY-NOTICES.mdfor what espOS links against. Redistribution of espOS and of firmware built on it is now permitted under the Apache-2.0 terms. - Shared build wrapper
scripts/build.sh: one lock per machine, half the cores,nice/ionice; consumers call it through the submodule. The documented build commands now go through it. - config: the descriptor generator moved from
tools/intocomponents/espos_config/tools/so a registry-installed copy of the component is self-contained;tools/espos_gen_config.pyis a shim for one release. The UI mock and the docs point at the new path. - docs:
docs/api.mdis nowdocs/rest-api.md(it is the REST contract, not the C API);docs/signalk.mdno longer names a Kconfig symbol that never existed and states the real server-list size;docs/development.mddescribestest/host/run_all.shdiscovery instead of a stale project list; the README milestone table became a short status and the plan-vs-implementation notes moved todocs/decisions.md.
Added¶
- n2k:
espos_n2k/examples/n2k_candump, an NMEA 2000 gateway — CAN frames off the bus, out over TCP as candump ASCII for canboatjs. Deferred until now for want of a CAN bus to verify against; the README carries what actually goes wrong (a transceiver and termination are not optional, and both fail looking exactly like a software fault) and howGET /api/v1/n2ktells a quiet bus from a misconfigured one. -
power:
espos_power, a deep-sleep duty cycle for devices on a battery — wake, publish, flush the SignalK stream, sleep forpower.interval_s, again. Off by default (power.mode), started byespos_start()when built, status atGET /api/v1/power, andespos_power_hold()/_release()for an application that needs a wake to last. The decision is pure C and host-tested, and its rules are about staying reachable: no sleep while an update is unconfirmed (every wake is a boot, and the bootloader aborts an image still pending verification, so sleeping first would roll every update back), an awake window after any boot that is not the cycle's own wake (power-on, update, crash) so the web UI and OTA can be reached, and a deadline on every wake so a missing network does not drain the battery. An update being checked, downloaded or installed holds the device awake past that deadline (espos_ota_busy()), or an update longer than a wake would never finish. Exampleduty_cyclefor the ESP32-C6 and the ESP32-P4 PoE board. -
eth:
espos_eth, wired Ethernet as anespos_nettransport -- the internal EMAC and an RMII PHY with DHCP, started byespos_start()beside the WiFi station.espos_netalready preferred Ethernet over WiFi, so the route moves to the cable once it has an address. The ESP32-P4 EMAC defaults are the Waveshare ESP32-P4-WIFI6-POE-ETH wiring; the PHY address and reset GPIO are Kconfig (defaults 1 and 51 on the P4). The PHY uses IDF's generic 802.3 driver, because IDF 6 moved the named ones to the registry. A PHY that does not answer is logged and does not failespos_start(), so a firmware that could come up on WiFi does not reboot-loop over a missing cable interface. Compiles to stubs on chips without an EMAC. -
prov:
espos_prov, BLE provisioning -- WiFi credentials from a phone over GATT, with no access point and no captive portal. Off by default (CONFIG_ESPOS_PROV, about 25 KB of flash).
Credentials only: espOS keeps the radio. Credentials arriving over BLE
are written to the wifi config namespace -- the same keys the web UI
writes -- and the existing WiFi state machine connects as it always does.
Nothing in this component calls esp_wifi_*. That is why it is built on
protocomm directly rather than on espressif/network_provisioning: that
component's manager drives the station itself (esp_wifi_set_storage,
esp_wifi_set_config and a connect timer, all before it invokes the
application's callback, with no Kconfig to turn it off), which would mean
two owners of one radio and two stores of one set of credentials.
The cost of that choice is compatibility: Espressif's ESP BLE Provisioning phone app speaks the manager's protobuf schema and will not talk to this. The endpoint here is plain JSON.
Known limitation, unresolved: on a firmware that also runs
espos_ble, protocomm_ble_start() fails ESP_ERR_INVALID_STATE with
Bluedroid already initialised. protocomm's simple_ble_start() calls
esp_bluedroid_init_with_cfg()/esp_bluedroid_enable() unconditionally --
it assumes it owns the whole stack -- and the gateway has already brought
it up. Provisioning and the BLE gateway therefore cannot both run in one
firmware yet; the failure is non-fatal and the device carries on scanning.
A provisioning-only firmware is unaffected.
espos_flow: the data-flow runtime — one loop task, a wrap-safe timer wheel, a cross-task mailbox, and a typed producer/consumer graph over them. Nodes are wired withconnect_to()or>>, cost no allocation after start-up and need no locks, because everything in a graph runs on one task and everything else enters through aMailbox.Poll,Lambda,Join,Sink,Value,Constant,TickerandMailbox;Graph::make<T>()or value semantics for ownership. A firmware that does not require the component links zero bytes of it (measured: the minimal example is byte-identical). See docs/flow.md.espos_formulas: the marine arithmetic as a header-only component with no dependencies at all — SI conversions for every unit the Signal K spec uses, curve interpolation, dew point (Arden Buck), heat index (the full NOAA regression with both adjustments), air density, resistive dividers, tank level and battery state of charge. It is separate fromespos_flowon purpose: the formulas are testable, and tested, without a graph, a task or a chip. See docs/transforms.md.- sk: inbound PUT — a Signal K client can operate a switch on an espOS
device, which was simply not possible before: the stream parser never looked
at
put.espos_sk_put_handler_register()/_unregister()answer a request automatically (200, 400 for an unusable value, 502, and 405 for a path with no handler), or returnESPOS_SK_PUT_PENDINGand answer later withespos_sk_put_respond(). Both wire forms are accepted: the array form signalk-server actually sends and the single-object form a client sends. Every request is answered, because silence costs the client a 60-second timeout. Note the server routes a PUT on(path, $source)it has observed, so a device must publish a path before it can be written to. - sk:
espos_sk_flush(timeout_ms)drains buffered deltas — the prerequisite for sleeping without losing the last samples.ESPOS_SK_MAX_META16 → 32, nowCONFIG_ESPOS_SK_MAX_META./api/v1/sk/statusgainsputs_inandputs_rejected. espos_sensors: the drivers a sensor firmware starts from, on IDF 6 APIs and as flow nodes — calibrated ADC reads in volts, GPIO in and out, pulse counting over PCNT with a GPIO-interrupt fallback on SoCs that have no PCNT unit (the ESP32-C3), LEDC PWM, a shared I2C bus for breakouts, and optional 1-Wire/DS18B20 (CONFIG_ESPOS_SENSORS_ONEWIRE, off by default and costing nothing when off). See docs/sensors.md.espos_sk_flow: the Signal K nodes for the graph —Output,Listener,PutHandler,PutRequest,Notify,NetRssi,IpAddress.Outputtakes metadata only together with a custom path, so the rule that spec paths never carry meta cannot be broken by accident. Thesmart_switchexample is the one that could not be written before inbound PUT existed.espos_flowtransforms: 33 nodes covering what a SensESP firmware wires daily —Linear,MovingAverage,Median,Ema,ChangeFilter,Debounce,Throttle,Threshold,Hysteresis,Curve,Frequency,RunHours,Expire,Repeat,Clamp,Deadband,RateOfChange,MinMaxHold,Latch,Counter,WrapAngle,Convertand the rest. A node's constant can be bound to a config namespace withParam, which makes it editable in the web UI, and the time-stamping transforms useespos_timewhen the firmware builds it; both are optional dependencies and the headers compile without them.- Config namespaces registered at run time:
espos_config_register_ns()/espos_config_unregister_ns()let a graph node publish its own settings (multiplier, offset, curve table) to the web UI and NVS without a build-time descriptor. A node id of at most 12 characters becomes the NVS namespacef_<id>; a duplicate, over-long or malformed one fails loudly and raises the health conditionflowConfig. espos_config_schema_json()serves the compiled schema merged with the runtime namespaces, under an ETag that moves on every register and unregister;GET /api/v1/config/schemaand/system/info'sschema_etagboth use it.- Descriptor fields
readOnly,groupand the presentation blockx(displayMultiplier,displayOffset,format: "table"withcolumns), accepted for build-time descriptors as well. Tables are string keys holding JSON, so an export stays readable and the UI edits rows without base64. - Web UI: groups render as tabs, read-only fields are shown but not editable, display multiplier and offset are applied on read and inverted on write, and table keys get a row editor.
-
CONFIG_ESPOS_CONFIG_MAX_RUNTIME_NS(default 32). -
time: new
espos_timecomponent — the device's wall clock, learned from SNTP, a manualPUT /api/v1/time,navigation.datetimeon the SignalK stream, or an instant carried through a deep sleep in RTC memory, ranked in that order so a coarse source never walks back one that is better. SNTP is armed at start and begins polling on the firstNETWORK_UP, whichever transport produces it.espos_time_now_ms()returns 0 while unsynced rather than a plausible-looking 1970, so a missing time reads as missing.GET/PUT /api/v1/time, atimeobject in/system/info, a Clock line on the status page, configtime.*, andESPOS_EVENT_TIME_SYNCED(docs/time.md). - sk: deltas carry the time their values were measured
(
updates[].timestamp, configsk.timestamps, default on). The engine records a monotonic stamp per queued message and converts it at send time, so a message buffered through an outage keeps its own time even when the clock was only set afterwards — previously the server stamped it on arrival and an hour of data landed as one burst at reconnect. - log:
CONFIG_ESPOS_LOG_WALLCLOCK(default on) adds the UTC time to stored log lines beside the existing milliseconds-since-boot. Console output and lines logged before the clock is set are unchanged. - sk: TLS to real boat servers. A trust store next to the token (NVS
skstate) pins what the server presents on the first connection that works and holds it to that afterwards, the way ssh does — so signalk-server's own self-signed certificate, and any private CA, now work instead of being refused. A CA anchor binds the issuing CA and the leaf's SAN set, so a renewal by the same CA for the same names is accepted with nobody pressing anything; a bare self-signed certificate is pinned as itself and a replacement needs one deliberate "trust the new certificate". There is no accept-anything mode.GET/DELETE /api/v1/sk/tls,PUT /api/v1/sk/tls/ca, SSEsk_tls. - sk: token state
cert_error— the certificate is not the trusted one. The token is kept (it is the transport that is wrong, not the credential), the retry is a flat 60 s rather than an exponential backoff, and the stream stays down rather than falling back to plaintext. Health conditionskCertificate. One TLS handshake at a time device-wide, with a pre-flight check on contiguous internal RAM (tlsMemoryWARN when it defers). espos_net: the interface-agnostic network seam — default route (Ethernet before WiFi before Thread),espos_net_get_status/is_up/subscribe/short_id/ backoff_ms, transports plug in throughespos_net_register_if/report;GET /api/v1/net/statusand thenetSSE event;net.hostname; the mDNS responder moved here unchanged.espos_skandespos_otadepend onespos_net, not onespos_wifi; a headless esp32h2 build (no WiFi at all) is a CI gate.docs/net.md.- wifi: static IP (
wifi.ip_mode,ip,netmask,gateway,dns0,dns1), applied before every connect. - httpd: REST authentication (closes #2).
httpd.api_key(secret; empty = open, the default) guards every endpoint registered throughespos_httpd_register():Authorization: Bearer <key>for machine clients, or theespos_sidcookie fromPOST /api/v1/auth/login(/logout,/status) for browsers; cookie writes need a matchingOrigin; five wrong keys in 60 s → 429 for 30 s; the setup-portal network is exempt (lockout recovery). Newespos_httpd_register_ex(uri, ESPOS_HTTPD_PUBLIC|PROTECTED),espos_httpd_request_authenticated(),espos_httpd_auth_policy.h; publicGET /api/v1/system/ping;GET /system/infois now protected. KconfigESPOS_HTTPD_AUTH_REQUIRED,ESPOS_HTTPD_MAX_SESSIONS,ESPOS_HTTPD_TLS(reserved). Consumers: an application's endpoints become protected automatically; an endpoint that must stay open registers withESPOS_HTTPD_PUBLIC; the cockpit's own :8081 server is unaffected. - ui: login page, Log out, a Generate button for
httpd.api_key(shown once), an Access card on Status; the mock serves/auth/*and/system/ping.
Changed¶
- sk:
sk.tls(bool) is replaced bysk.scheme(auto | http | https, defaultauto); the descriptor is at version 2 and a migration mapstls = trueto"https"and anything else to"auto". Consumers: a config export or an automation that writessk.tlsmust writesk.schemeinstead.autoreads the scheme off the server's own mDNS advertisement, or for a manual host off one unauthenticated redirect probe. The setting is no longerrestart_required. - sk:
CONFIG_ESPOS_SK_TLSnow defaults toy. Measured on esp32c6: about 10 KB withespos_otain the build (mbedTLS and the bundle are already there for the https image source), about 78 KB without it. New settingsk.tls_trust(tofu | ca | bundle, defaulttofu);bundleis the previous behaviour, andsk.ca_pemsupplies the CA forcamode so a fleet can connect verified on the first try. - sk: over plaintext a single 401 no longer clears the access token. Anything on the path can answer one — a captive portal, a proxy, a router's login page — and replacing a token costs a trip to the server's admin UI, so the first refusal buys a re-check 5 s later and only two in a row clear it. Over TLS one is still conclusive. On a TLS server the token machine also skips the VERIFYING leg, which only bought a second handshake per reconnect.
- Consumers, reflash required:
partitions/4mb.csvmoves 256 KB fromstorageinto the two app slots (ota_0/ota_11600K → 1728K,storage640K → 384K). A full build — WiFi, TLS-capable crypto, mDNS, LittleFS and the web UI — had reached 92 % of the old slot, leaving no room for the next feature or for an OTA image briefly larger than the running one; the UI bundle is 22 KB, so 384 KB is still seventeen times what it needs. A device on the old table keeps working, but takes the new layout only through a USB flash, not over the air. -
Consumers:
espos_wifi_start()requiresespos_net_start()first (espos_start()handles it: httpd → net → wifi → …).espos_wifi_short_id()andespos_wifi_backoff_ms()are deprecated, removed in 0.9 — useespos_net_*.wifi.hostname→net.hostname(migrated once at boot).CONFIG_ESPOS_WIFI_MDNS[_MAX_SERVICES]→CONFIG_ESPOS_NET_MDNS[_MAX_SERVICES]. The device id is the base MAC on every transport; on the ESP32-P4 the default hostname, portal SSID and Signal K source label change once — setnet.hostnameto keep a name. Deletebuild/sdkconfigonce after the bump. -
examples: eleven buildable example projects under
components/<c>/examples/, indexed inexamples/README.md, each a complete IDF project on the shared prologue (≤ 120 lines of C) naming the SensESP example it replaces. Core:minimal(the project a new firmware starts from),two_phase_boot,custom_settings,app_endpoint_and_page,health_and_led; Signal K with real peripherals:analog_input,pulse_counter,digital_switch,listener_relay,json_and_meta,tls_server.main/stays the all-components reference app. - wifi: the manifest names
espressif/esp_wifi_remotefor the ESP32-P4 next toesp_hosted(esp_hosted's own manifest declares onlyidf), so a consumer no longer repeats both lines; component manifests exclude example build state from registry packs. - docs: documentation site at signalk-espos.github.io/espOS — mkdocs-material
(
mkdocs.yml,docs/requirements.txt), strict build on every pull request and deploy frommain(.github/workflows/docs.yml); new pages: home, getting started, examples index, hardware, troubleshooting, roadmap, C API overview; the C API reference is generated fromcomponents/*/includeby Doxygen (Doxyfile) through mkdoxy; the changelog page is rendered from this file (docs/_hooks/root_files.py). - docs:
docs/migration-from-sensesp.md— the three things that change coming from SensESP, a symbol-by-symbol table (SensESP → espOS today → planned facade), a worked port of SensESP'sanalog_input.cpp, what has no equivalent yet, what espOS adds. Six tutorials underdocs/tutorials/(first-sensor, add-a-setting, tank-level, app-endpoint-and-ui-tab, ota-from-a-manifest, logs-and-core-dumps), labelled Essential / Newbie / Advanced.concepts.mdgains "From SensESP's model to espOS's". - Template:
scripts/sync_template.shgenerates the espos-template repository from theminimalexample (espOS as theespos/submodule, a five-target CI, README with the ten-minute path);scripts/build_example.shbuilds one example the way CI does. CI gains anexamplesmatrix (every example on esp32c6,minimalon all five targets) and size caps for tutorials. - build:
sdkconfig.d/debug.defaultsraises the maximum log level to debug soPUT /api/v1/logs/levelcan actually switch to it. espos_core:espos_start()one-call boot (log → config → httpd → wifi → sk → ota → ble, optional stacks only when built),espos_init(),espos_start_network(),espos_version(),espos_app_name(); KconfigESPOS_CORE_HEALTH_WATCHDOG(reserved for the health policy).espos_event: theESPOS_EVENTbase on the default loop withCONFIG_READY,HTTPD_STARTED,NETWORK_UP/DOWN,SK_SERVER_SELECTED,SK_TOKEN_APPROVED,OTA_AVAILABLE(MDNS_READY,SK_STREAM_*declared);espos_event_post/subscribe/unsubscribe.espos_config_is_ready().- wifi: mDNS responder with an API (
espos_mdns.h). The device answers for<wifi.hostname>.localand advertises_http._tcp(TXTpath=/) and_espos._tcp(TXTv,app,espos,target,id,api=/api/v1,auth=0) onhttpd.port.espos_mdns_add_service()/espos_mdns_remove_service()register application services at any time (queued until the responder is up,CONFIG_ESPOS_WIFI_MDNS_MAX_SERVICESslots, default 6);espos_mdns_is_ready()andESPOS_EVENT_MDNS_READY(posted on everyNETWORK_UP) say when the network can be reached.CONFIG_ESPOS_WIFI_MDNS(default y) builds it; off, or on the linux target, the API compiles to stubs. Replaces consumers' retry loops aroundmdns_service_add(). - core:
ESPOS_ABI_VERSION(1) inespos.handespos_abi_version(). The C headers undercomponents/*/includeare the stable contract a binding is generated from; rules in docs/development.md "Public API rules", the decision (C ABI, C++ facade above it, Rust not now) in docs/decisions.md. - tools:
tools/check_public_headers.pychecks the public headers against those rules — IDF includes beyondesp_err.hand the two frozen exceptions,#pragma once/extern "C",CONFIG_tokens as warnings; CI runs it as theheadersjob. - health: the device watchdog policy.
espos_health_policy_start()(armed byespos_start()) ticks every 10 s, raiseslowMemory(WARN 40 KB total / 20 KB internal; fatal ALARM 12 KB internal / 8 KB largest block) andtaskStalled, and restarts after 3 consecutive ticks with a condition raised viaespos_health_report_ex(..., ESPOS_HEALTH_F_REBOOT_ON_ALARM)in ALARM. WARN and unflagged ALARM never restart. Pure C behind a port (espos_health_policy.h), host-tested. - health:
espos_health_watch_task()/kick()/unwatch_task()subscribe a task to the IDF task watchdog (now 30 s, panic → core dump → reboot, rollback before OTA confirm) and to the policy; the SignalK stream task is watched. - health: reset record —
espos_health_last_reset();GET /api/v1/system/infogainslast_reset(nullunless the watchdog restarted the device). - core:
netDownWARN/NORMAL fromESPOS_EVENT_NETWORK_DOWN/UP, never fatal by construction. - sk:
skLinkStalledfatal condition (WiFi up, stream worked once, down forsk.stall_s— new key, default 300 s);ESPOS_EVENT_SK_STREAM_CONNECTED/ DISCONNECTEDare posted; telemetry addsespos.<label>.{internalFree, largestBlock}. - sk:
espos_sk_http.h, one HTTP client for the selected server:espos_sk_http_get/put/post/delete,espos_sk_get_value/meta,espos_sk_url/ws_url; freshesp_http_clientper call andperform()only (avoids the twohttp_on_bodyasserts seen on the P4), body cap withtruncated, Bearer from the token snapshot, scheme fromsk.tls, 401/403 reported to the token machine.CONFIG_ESPOS_SK_HTTP_MAX_CONCURRENT(default 2) bounds requests in flight. - build:
sdkconfig.d/release.defaults(flash + NVS encryption) anddebug.defaults;partitions/8mb.csv,partitions/16mb.csv;components/espos_core/project_include.cmakelints a registry consumer's sdkconfig (event/timer task stacks; P4 L2 cache line vs hosted mempool, BA window vs PSRAM);scripts/build.shenables ccache when installed. - Contributor tooling:
.clang-formatderived from the existing C style and a Google-style one for the C++ components, an advisory.clang-tidy,scripts/check_no_secrets.sh,tools/espos_size_diff.py(size report and app-slot budget fromidf.py size --format json2), Dependabot, issue and pull-request templates,CODEOWNERS,SECURITY.md,CONTRIBUTING.md, release-notes categories, and this changelog. - ota, ble, n2k, voice:
Kconfigmenus for the knobs that were literals (manifest size, boot-check delay, task stacks, advert batch, GATT sessions, candump port, RX queue depth, the legacy_sensesp-n2k._tcpservice type, Wyoming port); defaults equal the former values. tools/check_kconfig_docs.pyand a CI job: everyCONFIG_ESPOS_*the docs mention must exist in aKconfig.- CI:
reuse lint, clang-format on changed files, a tracked-secrets check, a per-target size report with a 90 % app-slot budget, and a component manifest pack dry-run. - Espressif Component Registry manifests for all eleven components (namespace
espos, version in lockstep withversion.txt, sibling dependencies viaoverride_path,idf >=6.0.0,<6.1.0); cJSON declared inespos_sk,espos_wifi,espos_otaandesp_hosted(P4) inespos_wifi, which their CMakeLists already required.docs/releasing.mdgains "Registry publishing".
Fixed¶
-
build: the prologue's
SIGNING_KEY <path>never reached IDF. It generated and fingerprinted the key at that path, but IDF signs withCONFIG_SECURE_BOOT_SIGNING_KEY, which stayed atsecure_boot_signing_key.pemin the project. With no key there the build failed; with one there it signed with that key while re-sign tracking followed the other, so an image could install over USB and then be refused by every OTA. The path now reaches IDF through a generated defaults fragment; a named key that is missing is an error instead of a freshly generated development key; and an existingsdkconfigthat names a different key stops the configure, because defaults do not reach it. No espOS consumer passedSIGNING_KEY, so no device got a wrongly signed image from this. -
P4: espOS's ESP32-P4 defaults enable PSRAM (
CONFIG_SPIRAM=y). They left it to the project, but without PSRAMesp_hosted's startup allocations leave so little internal RAM that FreeRTOS cannot allocate its timer task's stack, and the board panics within seconds of every boot (assert failed: vApplicationGetTimerTaskMemory port_common.c:97). Found installing a firmware built from these defaults on a Waveshare ESP32-P4 PoE board; the same image with only PSRAM added booted. Every P4 example exceptble_gateway, which set it itself, was affected: CI builds them but nothing ran them. The hosted mempool setting also depends on PSRAM and was being dropped silently.espos_core's configure lint now refuses a P4 build without PSRAM. Consumers that setCONFIG_SPIRAM=ythemselves can drop the line. -
sk:
espos_sk_flush()could returnESP_OKwhile the last message was still being written. The stream task takes a message off the queue and then spends up to the send timeout writing it, and for that time the message was counted neither as pending nor as buffered; a send that then failed requeued it after the caller had been told the stream was drained. A device that deep-slept on that answer lost its last reading. The message being written now counts aspendingin the stream status, so the flush waits for it. -
flow:
espos_flow_stop()'s documentation said pending timers are cancelled. They are not, andespos_flow_run_until_idle()after a stop relies on that; the header now says timers stay armed and how to keep posted work. -
sk: with
sk.schemeautoand a manually configured server, a scheme probe that reached nothing was remembered as plain http for that address. A device probes before its network is up, so a manual https server, or one that redirects to https, was never found; and a server that booted after the device, the usual order on a boat, was talked to over plain http from then on. The probe now tells "no answer" from "plain", only an answer is cached, and a guess is asked again when the network comes up and after every attempt. A manual host is also selected only once there is a network, which removes the ten-second error backoff every cold boot used to sit out before reaching its server. -
n2k:
esp_driver_gpiois a PUBLIC requirement, not a private one.twai_receiver.his a public header andTwaiReceiverConfigexposesgpio_num_t, so every consumer needsdriver/gpio.hon its include path — and a private requirement does not propagate. A project that required onlyespos_n2kcould not compile, failing inside espOS's own header. The cockpit never hit it because its HAL happens to requireesp_driver_gpiotoo; writing the first example that does not is what found it. -
ble/wifi: the setup portal was slow to join and often handed out no address at all. Two faults, both on the ESP32-P4 where the C6 co-processor is ONE radio serving WiFi and BLE.
The scan suspension added for the portal never took effect at boot.
espos_ble_start() ran first and armed a scan, and arming is asynchronous
-- esp_ble_gap_set_scan_params() returns at once and the scan only starts
when the controller acknowledges -- so the suspension that followed found
s_scanning still false, stopped nothing, and the scan started a
millisecond later. The log said "scanning suspended" and the scanner then
held half the airtime for the whole session. The hold is now taken BEFORE
the gateway starts, so it never arms a scan; and espos_ble_scan_stop()
marks a scan stopped while it is still arming, so a pending start does not
run on regardless.
And the DHCP server could end up bound while the access point was down.
esp_wifi_set_mode(APSTA) brings the AP up carrying the driver's default
configuration and esp_wifi_set_config() then applies ours, which restarts
it; IDF starts the DHCP server off the netif's up-event, so the restart
could leave nothing serving and a client would associate and never get a
lease. The portal now makes sure the server is running. (Configuring before
the mode is not available: esp_wifi_set_config(WIFI_IF_AP) answers
ESP_ERR_WIFI_MODE while the current mode has no AP.)
- ui: the setup portal could sit on a blank page for ever.
useStore()subscribed to its store from an effect, and effects run after paint, so a value written between render and subscribe notified nobody -- and nothing would notify again.bootstrapAuth()resolving quickly did exactly that, leavingauthStoreset but the component still rendering its first read.
It looked like a device fault and was not. Over WiFi /auth/status answers
in about 8 ms and loses the race, so the app renders; on the setup portal it
is slower, wins, and the page never moves. useStore() now re-reads when
the effect runs.
Two things found while chasing it, both worth keeping on their own:
fetch() had no timeout, so one wedged request was indistinguishable from a
dead device (now 15 s); and the shell rendered null while authentication
was pending, which showed as a black page with nothing to explain it (now a
"Loading..." line).
-
ui: the built
index.htmlno longer marks its module script and stylesheetcrossorigin. The device serves noAccess-Control-Allow-Origin-- the bundle is same origin and does not need one -- and a browser that takes the attribute at its word fetches in CORS mode and blocks the script outright. -
ble/wifi: the setup portal was painfully slow to join on the ESP32-P4 -- half a minute to get a DHCP lease, minutes to reach the page, sometimes never. The C6 co-processor is ONE radio serving both WiFi and BLE, and the gateway scans 160 ms out of every 320 ms, so the SoftAP lost half its beacons and the association and DHCP exchange with them. The scanner now stands down while the portal is up (new
ESPOS_EVENT_PORTAL_UP/PORTAL_DOWN, andespos_ble_scan_suspend()/_resume()), and picks up again when the portal closes. A device showing its setup portal has nowhere to publish advertisements to yet, so nothing is lost.
Two details that are easy to get wrong and are worth knowing if you add a
second radio user. Bluedroid keeps exactly one GAP callback --
esp_ble_gap_register_callback() is a setter, not a subscribe -- so a
component that registers its own (protocomm's simple_ble, for instance)
silently takes the gateway's, after which scan results stop arriving with
no error reported anywhere; espos_ble_scan_resume() reclaims it. And
suspensions are counted, because with a plain flag a second holder
releasing its suspension handed the radio back while the portal still
needed it -- observed on hardware as suspended followed by resumed
150 ms later, with the portal still up.
GET /api/v1/ble/status gains scan_suspended, so a device that has
deliberately stopped scanning is distinguishable from one whose radio
failed.
-
httpd: the event-stream on-connect table held four callbacks while espOS itself ships five publishers (
net,wifi,sk,ota,ble), so on a firmware with more than four the last to register never delivered the snapshot a freshGET /api/v1/eventsclient is documented to receive. Found on the BLE gateway, whereespos_blewas the one that lost: it loggedstatus endpoint unavailable: ESP_ERR_NO_MEMat boot and the web UI's BLE panel then stayed empty until the first periodic update. The symptom is quiet by construction — the REST endpoint still answers and later changes are still published, so the component reads as idle rather than unregistered, and the error names memory while nothing is short of memory. The limit is nowCONFIG_ESPOS_HTTPD_SSE_MAX_CONNECT_CBS(default 8, was a hard-coded 4) and overflowing it is logged with the symbol to raise. Consumers registering their own publishers register after espOS's, so they are the ones that hit the cap; raise it rather than reordering. -
sk: a change of scheme was accepted by the configuration and then silently dropped by the token machine, whose "same server" test compared only host and port. A device switched between http and https kept using the old one.
-
formulas: curve interpolation no longer extrapolates below its first sample. SensESP's
CurveInterpolatordivides by the gap between two points that do not exist for an input under the table's first x, publishing NaN to the server for what is usually a cold sender at rest (SensESP #1005); a duplicated x in the table is a second 0/0. Out-of-range inputs now clamp to the nearest sample and a duplicated x is rejected when the table is set. - formulas: dew point uses the Arden Buck equation and is defined at 0 %
humidity, where the Magnus form SensESP uses returns
-inf; heat index applies both NOAA adjustments (low humidity at high temperature, high humidity in the mid 80s °F), which SensESP omits, and is only evaluated where the regression is valid. - formulas: a resistive divider with an open circuit — a disconnected sender, the single most common failure on a boat — returns no value instead of infinity. SensESP publishes the infinity, and a tank gauge downstream reads it as full.
- flow:
ChangeFilterno longer lets a value through because it rejected too many. SensESP'smax_skipsforces the next sample out after N consecutive rejections, so a stuck or glitching sender defeats exactly the filter meant to suppress it; a rejected value is now simply not emitted. - test:
tools/check_public_headers.pyreads.hppas well as.h. It globbed only*.h, so the C++ components' public headers were not exempt from the C ABI rules but invisible to the check, and the summary line reported a header count that silently excluded them — 48 where the tree has 67. The four new C++ components are declaredCPP_ONLYlike the three that were already there, which surfaces three Kconfig-in-a-public-header warnings that had been hidden. - test:
test/host/run_all.shtakes a per-user lock, so two concurrent runs can no longer rebuild and delete each other's test binaries and report failures that are not in the code. It is a different lock from the firmware build's — a host run and a build need not exclude each other. -
test: the REST harness no longer assumes the last
/signalkrequest in the mock server's log is its own. The firmware probes that path itself to settlesk.scheme, deliberately without a token, so the Bearer-header assertion could read the probe instead of the request under test. -
config:
espos_gen_config.pyemits valid empty tables and an empty schema when no descriptor is registered (was a build error for a consumer that declares none). - ble: report allocatable heap, and never wait on the BT task (#35).
0.7.0 - 2026-09-06¶
Fixed¶
- voice: gate on-device wake during playback and the echo tail, so the satellite does not wake on its own reply (#34).
- wifi: the esp_hosted transport mempool prefers PSRAM on the ESP32-P4, keeping internal RAM for what needs it (#33).
- voice: hold the wake stream on the device's own voice; skip only the on-device engine, not the network one (#32).
- release: a first tag gets release notes, and the release is published on GitHub (#31).
0.6.0 - 2026-08-28¶
First tagged release: config store, HTTP server and REST API, WiFi state machine with captive portal, SignalK discovery, token and delta stream in both directions, web UI, device health, signed OTA with rollback, and the BLE, NMEA 2000 and voice components. Earlier history is in git.