Skip to content

Tutorial: add a setting

Newbie. A number your code uses becomes a field on the Config page, a key in the REST document and a validated NVS value — by writing one JSON entry. Descriptor key → generated constant → change callback → the UI, in that order. Start where first-sensor ends (any project that publishes a value will do); components/espos_config/examples/custom_settings is the finished form.

1. The descriptor

A setting is declared once, in a JSON descriptor per NVS namespace (config.md). Create main/config/light.json:

{
  "namespace": "light",
  "version": 1,
  "title": "Light sensor",
  "keys": [
    {"name": "multiplier", "type": "float", "default": 1.0, "min": -100, "max": 100,
     "title": "Multiplier", "description": "Reading is multiplied by this before publishing."},
    {"name": "offset", "type": "float", "default": 0.0, "min": -10, "max": 10, "title": "Offset"},
    {"name": "period_ms", "type": "int", "default": 500, "min": 100, "max": 60000, "unit": "ms",
     "title": "Read interval"},
    {"name": "enabled", "type": "bool", "default": true, "title": "Publish"}
  ]
}

Namespace and key names are NVS identifiers: lower case, at most 15 characters. default must satisfy the constraints you give — the generator checks that at build time. Register the file in main/CMakeLists.txt, after idf_component_register, and add espos_config to PRIV_REQUIRES:

idf_component_register(SRCS main.c PRIV_REQUIRES espos_core espos_config espos_sk esp_adc)
espos_config_add_descriptor(config/light.json)

2. The generated constants

Build once (espos/scripts/build.sh build). The generator merged every registered descriptor — espOS's own wifi, httpd, sk, ota and now light — into build/esp-idf/espos_config/gen/include/espos_cfg_keys.h: ESPOS_CFG_NS_LIGHT is "light", ESPOS_CFG_LIGHT_MULTIPLIER is "multiplier", one constant per key; plus the validation tables writes are checked against and the JSON Schema served at GET /api/v1/config/schema. Code never spells an NVS key by hand: a typo in a constant is a compile error, a typo in a string literal is a default silently read back. A duplicate namespace or an over-long name fails the build with the file named.

3. Read it, and follow changes

Include espos_cfg_keys.h and espos_config.h. Reads never fail for a declared key — a missing or corrupt stored value reads as the default — so loading is four calls, and the same function serves as the change callback:

static float s_mult = 1.0f, s_offset = 0.0f;   /* word-sized: written by the config caller, read by the loop */
static int32_t s_period_ms = 500;
static bool s_enabled = true;

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_LIGHT, ESPOS_CFG_LIGHT_MULTIPLIER, &s_mult);
    espos_config_get_float(ESPOS_CFG_NS_LIGHT, ESPOS_CFG_LIGHT_OFFSET, &s_offset);
    espos_config_get_i32(ESPOS_CFG_NS_LIGHT, ESPOS_CFG_LIGHT_PERIOD_MS, &s_period_ms);
    espos_config_get_bool(ESPOS_CFG_NS_LIGHT, ESPOS_CFG_LIGHT_ENABLED, &s_enabled);
}

In app_main(), after espos_start():

    load_cfg(NULL, NULL, NULL);
    ESP_ERROR_CHECK(espos_config_subscribe(load_cfg, NULL));
    for (;; vTaskDelay(pdMS_TO_TICKS(s_period_ms))) {
        /* … read mv as in first-sensor … */
        if (s_enabled) {
            espos_sk_publish_number(PATH, mv / 3300.0 * s_mult + s_offset);
        }
    }

The callback runs once per changed key, on the task that wrote — an HTTP handler for a PUT, your own task for espos_config_set_*() — with the store lock released, so it may read config freely. Keep it short and the shared state word-sized; a string setting is read where it is used, or copied under a lock of your own. The loop picks the new period up on its next tick.

4. See it

Flash, then talk to the device (the Content-Type header is the CSRF guard — without it every write is 415):

H='Content-Type: application/json'; D=http://espos-xxxx.local/api/v1
curl -s "$D/config?ns=light"                                    # {"light":{"multiplier":1,"offset":0,"period_ms":500,"enabled":true}}
curl -s -X PUT -H "$H" -d '{"light":{"multiplier":2.5}}' $D/config   # {"changed":["light.multiplier"],"restart_required":false}
curl -s -X PUT -H "$H" -d '{"light":{"period_ms":50}}' $D/config     # 400 {"error":"validation","path":"light.period_ms","message":"out of range [100,60000]"}
curl -s -X PUT -H "$H" -d '{"light":{"multiplier":null}}' $D/config  # back to the default
curl -s "$D/config/schema" | python3 -m json.tool | grep -A3 '"light"'

The value in the Data Browser doubles after the second command, without a reboot. Open http://espos-xxxx.local/config: a Light sensor section with a number field per key, the ms unit and the ranges as hints, a checkbox for enabled — rendered from the schema, no UI code written. curl -N $D/events shows a config event {"ns":"light","key":"multiplier"} per change, which is how the page knows to refresh.

Growing it later

Adding a key needs no version bump — new keys read their defaults. Renaming, retyping or changing units bumps version and comes with a migration step (config.md, "Migrations"). "restart_required": true marks a key with ↻ in the UI and in the PUT response; "secret": true keeps a value out of every export. What a firmware can be configured with is decided at build time, which is what makes the schema an honest contract.