Migrating from SensESP¶
For people who have a SensESP project — PlatformIO, the Arduino core, a
SensESPAppBuilder, a connect_to() chain ending in an SKOutputFloat —
and want the same device on espOS. What you know carries over: Signal K paths
and SI units, metadata for non-standard paths, the approval step on the
server, the shape of every sensor program (read, calibrate, publish). What
changes is below. concepts.md is the map of what you are moving
onto; the tutorials start with first-sensor.
The three things that change¶
1. Toolchain: PlatformIO and Arduino → ESP-IDF 6 and idf.py¶
SensESP builds with PlatformIO on the Arduino-ESP32 core. espOS is native
ESP-IDF 6.0.x (the release named in .idf-version) with no Arduino layer: CMake
projects, Kconfig, idf.py (or scripts/build.sh, which wraps it) and the
IDF drivers — esp_adc instead of analogReadMilliVolts(), esp_driver_gpio
instead of digitalRead(), i2c_master instead of Wire, esp_driver_pcnt
instead of an interrupt counter. String and ArduinoJson are gone; the espOS
API is plain C with esp_err_t returns, cJSON is there when you need JSON.
Your own code may be C++ (espos_n2k and espos_voice are), but there is no
setup()/loop(): the entry point is app_main(), and a task that wants to
run forever loops itself. A project is a five-line root CMakeLists.txt plus
main/ (development.md, "Building a firmware on espOS");
platformio.ini has no counterpart — target, flash and partitions come from the prologue, pins are #defines in your code.
2. Model: a producer/consumer graph → plain C calls, today¶
In SensESP, objects are created in setup() and wired with connect_to();
the event loop ticks them and values flow along the graph. espOS has no graph
today. The runtime gives you the plumbing (WiFi, config, HTTP, the Signal K
token and the delta stream) and a small C API; your application is a task or
a timer that reads the sensor, does its arithmetic in C and calls
espos_sk_publish_number(). Batching, offline buffering, reconnects and
metadata reconciliation happen behind that call (signalk.md).
Honestly stated: a typed C++ facade — producer/consumer/transform nodes,
Poll<T>, Linear, MovingAverage, sk::Output<T>, chained with
connect_to() or >> — is planned as a later tranche (espos_flow). It does
not exist yet; the names in the "planned" column below come from the plan and
may change. It will be header-only sugar over the C API described here, never
a replacement for it: the C shape you write today is what it will produce.
3. Config: ConfigItem per object → one JSON descriptor per namespace¶
In SensESP every configurable object has a config_path, serialises itself
(to_json/from_json), carries a schema per class, and ConfigItem() puts
a card for it in the web UI. In espOS a setting exists in exactly one place: a
JSON descriptor per NVS namespace, registered from CMakeLists.txt with
espos_config_add_descriptor(config/<ns>.json). The build generates the key
constants (ESPOS_CFG_<NS>_<KEY>), the validation tables and the JSON Schema;
the web UI renders the form from the schema; PUT /api/v1/config validates a
whole document before writing any of it; descriptor versions drive migrations
(config.md). Code reads a setting with espos_config_get_*() and
follows changes with espos_config_subscribe(). Title and description live in
the descriptor, restart_required replaces set_requires_restart(), there is
no sort order (namespaces appear in name order). Settings are declared, not
owned by objects — which is why one survives a rewrite of the code using it.
Symbol by symbol¶
| SensESP | espOS today | Planned facade |
|---|---|---|
SensESPAppBuilder … get_app() |
espos_start(NULL) (espos.h); options in espos_start_opts_t (app_name, before_network, health_watchdog) |
same call underneath |
set_hostname("x") |
the wifi.hostname setting (portal, UI, PUT /api/v1/config); default espos-<id>. espos_start_opts_t.app_name names the device in the server's access-request list |
same |
set_wifi_client(ssid, psk) |
never in code: the portal, or wifi.ssid0/psk0 flashed as an NVS image (wifi.md) |
same |
set_sk_server(host, port) |
sk.server_host/sk.server_port; unset = mDNS discovery, sticky to the server the token belongs to |
same |
SetupLogging() |
nothing to call: espos_start() installs the log ring; level via CONFIG_LOG_DEFAULT_LEVEL_*, at runtime PUT /api/v1/logs/level |
same |
event_loop()->onRepeat() / onDelay() / onInterrupt() |
a FreeRTOS task with vTaskDelay() or an esp_timer / esp_timer_start_once() / gpio_isr_handler_add() posting to a queue your task reads |
espos_flow_every(), Poll<T> / espos_flow_after() / GpioChange |
RepeatSensor<T>(ms, lambda) |
the loop in components/espos_sk/examples/analog_input: read, compute, espos_sk_publish_*() |
Poll<T> |
AnalogInput |
esp_adc oneshot + eFuse calibration (analog_input example, worked below) |
sensors::Analog |
DigitalInputCounter / Change / State |
esp_driver_pcnt (components/espos_sk/examples/pulse_counter) / GPIO interrupt + queue / gpio_get_level() in the loop (components/espos_sk/examples/digital_switch) |
PulseCounter / GpioChange / GpioState |
DigitalOutput |
gpio_set_level() (components/espos_sk/examples/listener_relay) |
GpioOutput |
Linear / MovingAverage / Frequency |
v * m + b with m, b as descriptor keys (analog_input example) / a ring of n floats and a running sum / count ÷ elapsed esp_timer_get_time() (pulse_counter example) |
Linear / MovingAverage / Frequency |
Hysteresis / FloatThreshold / LambdaTransform |
an if with two thresholds and a remembered state / a comparison / a C function |
Hysteresis / Threshold / Lambda |
SKOutputFloat, SKOutputInt / SKOutputBool / SKOutputString / SKOutputRawJson |
espos_sk_publish_number() / _bool() / _string() / _json() — thread-safe, never block, work before WiFi is up |
sk::Output<T> |
SKMetadata |
espos_sk_declare_meta(path, meta_json, period_ms) — non-standard paths only; reconciled on every connect, server edits win (components/espos_sk/examples/json_and_meta) |
sk::Meta |
SKValueListener<T> |
espos_sk_subscribe(pattern, period_ms, cb, arg); families (navigation.*) too (listener_relay example) |
sk::Listener<T> |
SKPutRequest<T> |
espos_sk_put(path, value_json, cb, arg) |
sk::PutRequest<T> |
SKPutRequestListener<T> |
not yet — no inbound PUT handler; a server PUT to the device's path is answered 405 by the server |
sk::PutHandler<T> |
ConfigItem(obj)->set_title()->set_description()->set_requires_restart() |
one key in the namespace's descriptor: title, description, restart_required; read with espos_config_get_*(), follow with espos_config_subscribe() (components/espos_config/examples/custom_settings: string, enum, a version-2 migration) |
Param<T> |
SystemStatusLed |
an espos_health_add_sink() sink plus ESPOS_EVENT handlers driving a GPIO (components/espos_health/examples/health_and_led) |
same |
SensESPMinimalAppBuilder |
espos_init() without espos_start_network() (components/espos_core/examples/two_phase_boot), or a project that does not require espos_sk |
same |
StatusPageItem / UIButton |
your own GET/POST endpoints via espos_httpd_register(), espos_httpd_sse_publish(), and a registerPage() tab (components/espos_httpd/examples/app_endpoint_and_page, ui.md) |
a generic Flow page |
enable_system_info_sensors() |
built in: espos.<hostname>.{uptime,freeHeap,internalFree,rssi,…} every sk.health_s |
same |
enable_ota(password) |
espos_ota: signed images, rollback, a version manifest (ota.md); the signature is the credential |
same |
enable_wifi_watchdog() |
espos_health's policy: a lost link is a WARN that never restarts; a stalled Signal K link over a link that claims to be up does (health.md) |
same |
A worked migration: examples/analog_input.cpp¶
SensESP's example reads a light sensor on an analog pin every 500 ms,
calibrates it with a Linear the user can adjust in the UI, and sends the
result as a ratio on environment.indoor.illuminance with metadata. Stripped
of comments:
#include "sensesp/sensors/sensor.h"
#include "sensesp/signalk/signalk_output.h"
#include "sensesp/transforms/linear.h"
#include "sensesp_app_builder.h"
using namespace sensesp;
void setup() {
SetupLogging();
SensESPAppBuilder builder;
sensesp_app = builder.get_app();
auto* analog_input = new RepeatSensor<float>(500, []() {
return analogReadMilliVolts(34) / 1000.;
});
auto* cal = new Linear(1.7007, -0.1650, "/indoor_illuminance/linear");
ConfigItem(cal)->set_title("Input Calibration")->set_description("Analog input value adjustment.");
analog_input->connect_to(cal)->connect_to(new SKOutputFloat(
"environment.indoor.illuminance", "", new SKMetadata("ratio", "Indoor light")));
}
void loop() { event_loop()->tick(); }
The espOS version is components/espos_sk/examples/analog_input, condensed
here to what corresponds (the example adds the calibration-scheme #if for the
original ESP32, 16-sample averaging and a log line). The Linear's parameters
become a descriptor, main/config/app.json — the example ships 1.0/0.0; a port carries SensESP's constants:
{"namespace": "app", "version": 1, "title": "Analog input",
"keys": [
{"name": "multiplier", "type": "float", "default": 1.7007, "title": "Multiplier"},
{"name": "offset", "type": "float", "default": -0.165, "title": "Offset", "description": "Analog input value adjustment."},
{"name": "period_ms", "type": "int", "default": 500, "min": 100, "max": 60000, "unit": "ms", "title": "Sample period"}]}
registered in main/CMakeLists.txt next to the IDF ADC driver:
idf_component_register(SRCS main.c PRIV_REQUIRES espos_core espos_config espos_sk esp_adc)
espos_config_add_descriptor(config/app.json)
and main/main.c is the whole program — RepeatSensor is the loop, Linear
is one multiply-add, SKOutputFloat is one publish call:
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali_scheme.h"
#include "espos.h"
#include "espos_cfg_keys.h"
#include "espos_config.h"
#include "espos_sk.h"
#define ADC_GPIO 4 /* ADC1 on the ESP32-C6 DevKitC (GPIO 0..6); the example uses 20 on the ESP32-P4 */
#define SK_PATH "environment.inside.illuminance" /* a spec path: the server owns its meta */
static adc_oneshot_unit_handle_t s_adc;
static adc_cali_handle_t s_cali;
static adc_channel_t s_chan;
static float s_multiplier = 1.7007f, s_offset = -0.165f; /* word-sized: written on the HTTP task, read by the loop */
static int32_t s_period_ms = 500;
/* Initial load and the change callback (runs on the writer's task, usually an HTTP handler). */
static void load_cfg(const char *ns, const char *key, void *arg)
{
(void)ns; (void)key; (void)arg;
espos_config_get_float(ESPOS_CFG_NS_APP, ESPOS_CFG_APP_MULTIPLIER, &s_multiplier);
espos_config_get_float(ESPOS_CFG_NS_APP, ESPOS_CFG_APP_OFFSET, &s_offset);
espos_config_get_i32(ESPOS_CFG_NS_APP, ESPOS_CFG_APP_PERIOD_MS, &s_period_ms);
}
static void adc_init(void)
{
adc_unit_t unit;
ESP_ERROR_CHECK(adc_oneshot_io_to_channel(ADC_GPIO, &unit, &s_chan));
adc_oneshot_unit_init_cfg_t ucfg = { .unit_id = unit };
ESP_ERROR_CHECK(adc_oneshot_new_unit(&ucfg, &s_adc));
adc_oneshot_chan_cfg_t ccfg = { .atten = ADC_ATTEN_DB_12, .bitwidth = ADC_BITWIDTH_DEFAULT };
ESP_ERROR_CHECK(adc_oneshot_config_channel(s_adc, s_chan, &ccfg));
adc_cali_curve_fitting_config_t cal = { .unit_id = unit, .chan = s_chan, .atten = ADC_ATTEN_DB_12, .bitwidth = ADC_BITWIDTH_DEFAULT };
ESP_ERROR_CHECK(adc_cali_create_scheme_curve_fitting(&cal, &s_cali)); /* eFuse data: what analogReadMilliVolts() used */
}
void app_main(void)
{
ESP_ERROR_CHECK(espos_start(NULL)); /* WiFi, portal, token, stream, UI, OTA */
load_cfg(NULL, NULL, NULL);
ESP_ERROR_CHECK(espos_config_subscribe(load_cfg, NULL));
adc_init();
for (;; vTaskDelay(pdMS_TO_TICKS(s_period_ms))) { /* RepeatSensor<float>(500, …) */
int raw, mv;
ESP_ERROR_CHECK(adc_oneshot_read(s_adc, s_chan, &raw));
if (adc_cali_raw_to_voltage(s_cali, raw, &mv) == ESP_OK) {
espos_sk_publish_number(SK_PATH, mv / 1000.0f * s_multiplier + s_offset); /* Linear → SKOutputFloat */
}
}
}
One deliberate change: SensESP's environment.indoor.illuminance is not a
specification path, which is why its example had to send SKMetadata("ratio", …);
the espOS example publishes the spec path environment.inside.illuminance, whose
units the server knows. To keep a path of your own, SKMetadata is one line before the loop:
espos_sk_declare_meta(SK_PATH, "{\"units\":\"ratio\",\"displayName\":\"Indoor light\"}", s_period_ms);.
WiFi, hostname and server are configuration, entered once through the portal or
the UI; the calibration is edited on the Config page (or PUT /api/v1/config
{"app":{"multiplier":2}}) and reaches the loop through load_cfg. Nothing here knows about WebSockets, tokens or reconnects.
What has no equivalent yet¶
- Arduino driver libraries (Adafruit, SparkFun, …) do not compile on
IDF. Check the Espressif component registry first (
idf.py add-dependency "<ns>/<name>"), then port: an I²C breakout is a few register reads. With IDF 6'si2c_master:i2c_new_master_bus()once (sda_io_num,scl_io_num,clk_source = I2C_CLK_SRC_DEFAULT),i2c_master_bus_add_device()per address,i2c_master_transmit_receive()to write a register address and read bytes back,i2c_master_transmit()to write. Translate the library'sreadRegister()/writeRegister()pair, keep its formulas, drop the rest. Run the bus from your own task, never from a callback espOS calls you on. - A permanent access point. SensESP keeps its AP up next to the station.
espOS brings the portal up only while provisioning (no network configured,
or retrying for
wifi.portal_after_s) and drops it once the station connects (wifi.md). - Ethernet. SensESP has an ESP32-P4 Ethernet provisioner; espOS is WiFi
only today (
espos_netwith a wired interface is on the roadmap). - PUT handlers (
SKPutRequestListener): the device cannot yet accept a PUT from the server; until it can, a remote switch subscribes to its own path. - The transforms library (
CurveInterpolator,Median,Debounce,Join/Zip,Throttle, …): write the arithmetic in C for now; the facade brings them back as nodes. - Web UI login (
set_admin_user): the REST API and the UI have no authentication today (rest-api.md).
What espOS has that SensESP does not¶
- Signed OTA with rollback and a manifest. Every image is RSA-signed and
verified on the device; a new image must confirm itself (WiFi up) or the
bootloader boots the previous slot; a static
manifest.jsonon any web server is a fleet update channel (ota.md, tutorial). - A token state machine that survives reality: a persistent
clientId, the token keyed by the server'sselfso a server that changes address keeps working, a pending request that resumes after a reboot, manual paste, re-request fromdenied(signalk.md). - Reason-coded WiFi:
reason: {code, text}for every failure ("wrong password", "network not in range", "associated but no IP"), four networks in priority order, BSSID pinning, exponential backoff, a co-processor link watchdog on the ESP32-P4 (wifi.md). - A health policy: level-triggered conditions with sinks,
lowMemorymeasured on internal RAM, watched tasks, a strike-counting watchdog that never restarts on a lost network, a reset record the next boot can read (health.md). - Host tests: state machines, parsers and the REST server run on the linux target under Unity and a Python harness (development.md).
- Six targets from one tree: ESP32, S3, C3, C6, C5 and P4 (WiFi over an
ESP32-C6 co-processor), same code, same
sdkconfig.d/defaults. - Smaller things you will miss going back: the log ring and core dump over
REST (tutorial), SSE instead of polling,
validated config export/import, descriptor migrations,
_espos._tcprecords.