Config Variables (v0.4.0)

Coming in v0.4.0. This feature is on the roadmap. The API and field names below describe the planned design and may change before release.

libscadable v0.4.0 adds config variables — typed key/value settings you declare once in your repo, set from the SCADABLE dashboard, and read at runtime on the device. Change a value in the dashboard and it syncs down; the device re-syncs automatically every time it reconnects.

You declare a variable in one place (.scadable/config.yaml). The build does the rest: it validates your firmware against the declaration, makes the variables available in your code, and uploads the declaration to the platform so the dashboard knows about them.

The whole loop

.scadable/config.yaml         →  you declare variables, once
        │ build
        ▼
platform learns the schema    →  dashboard shows + lets you set values
        │ you set values (org / namespace / device)
        ▼
device receives resolved map  →  read with scadable_config_*()
        │ heartbeat echoes version
        ▼
dashboard shows synced / pending

1. Declare

Add a config: block to .scadable/config.yaml:

# .scadable/config.yaml
device_type: esp32-s3

config:
  sample_rate_hz: { type: int,   default: 10,   min: 1, max: 1000, description: Sensor sampling rate }
  pump_enabled:   { type: bool,  default: false }
  target_temp_c:  { type: float, default: 21.5, min: 0, max: 100 }
  mode:           { type: enum,  values: [eco, normal, boost], default: normal }
FieldRequiredNotes
typeyesint, float, bool, string, or enum
defaultyesUsed until a value is set in the dashboard
min / maxnoNumeric range (int / float)
valuesenum onlyAllowed values
maxlenstring onlyMax length in bytes
descriptionnoShown as help text in the dashboard

Key names are lowercase snake_case. The sys. prefix is reserved for the library (heartbeat interval, log flush, and — later — OTA); you can't declare keys under it.

2. The build syncs everything

On your next build, the SCADABLE build pipeline:

  1. Validates the config: block (types, ranges, duplicate keys, reserved-prefix collisions). A bad declaration fails the build.
  2. Wires the variables into your firmware so you can reference them safely. Referencing a key that isn't declared is a build error — your code and your declaration can't drift apart.
  3. Uploads the schema to the platform. This is the moment the dashboard learns your variables. They appear under the namespace's Config tab only after a successful build.

This is consistent with the rest of the .scadable/ folder spec: your repo is the source of truth for what variables exist; the dashboard owns what their values are.

3. Set values in the dashboard

Values resolve through a hierarchy, lowest priority first:

schema default  →  org  →  namespace  →  device

Each level overrides only the keys it sets; everything else falls through. Set a value at the org level to default it fleet-wide, at the namespace level for one deployment, or at the device level to override a single unit. The device only ever receives the final, flat, resolved map — it never reasons about the hierarchy.

4. Read on the device

#include "scadable.h"

int32_t scadable_config_int  (const char *key, int32_t fallback);
bool    scadable_config_bool (const char *key, bool    fallback);
float   scadable_config_float(const char *key, float   fallback);
size_t  scadable_config_str  (const char *key, char *buf, size_t len); // 0 if absent

The fallback is returned if the key is missing or the wrong type — so your code is safe even on first boot, before any platform value has arrived.

void scadable_user_main(void) {
    my_wifi_connect();

    while (1) {
        int  rate = scadable_config_int ("sample_rate_hz", 10);
        bool pump = scadable_config_bool("pump_enabled",  false);

        char mode[16];
        scadable_config_str("mode", mode, sizeof(mode));

        run_cycle(rate, pump, mode);
        vTaskDelay(pdMS_TO_TICKS(1000 / rate));
    }
}

React to changes

Register a callback to be notified the moment a new config is applied, instead of polling every loop:

typedef void (*scadable_config_cb_t)(void *ctx);
void scadable_config_on_change(scadable_config_cb_t cb, void *ctx);
static void on_config(void *ctx) {
    int rate = scadable_config_int("sample_rate_hz", 10);
    retune_sampler(rate);            // re-read only what you care about
}

void scadable_user_main(void) {
    scadable_config_on_change(on_config, NULL);
    my_wifi_connect();
    /* ... */
}

Sync behavior

  • The resolved map is delivered over a retained MQTT message. "Retained" means the broker re-delivers the latest map the instant your device reconnects — so a device that's been offline catches up automatically on its next connect, with no fetch call from you.
  • The library caches the last map in NVS, so your scadable_config_* reads return the last-known values even before the network is up.
  • Every map is version-stamped. The device echoes the applied version in its heartbeat, so the dashboard shows each device as synced (on the latest values) or pending (a change hasn't reached it yet).
  • All of this runs in the library's background tasks. It does not block or involve your scadable_user_main loop.

Reserved keys (sys.*)

The library manages a few keys itself. You don't declare or read these — they're applied automatically in the background:

KeyEffect
sys.heartbeat_interval_msHow often the device heartbeats
sys.log_flush_interval_msHow often buffered logs are uploaded

These flow through the same dashboard config mechanism as your own variables — they're just owned by the library instead of your app. (This replaces the old fixed two-field device config.)

Coming later: OTA becomes a reserved key too. A sys.firmware_* key in the same resolved map will let the device pick up a new firmware image on sync and switch over via the existing OTA machine — no separate command path. When that lands, your config code doesn't change; OTA just rides the same channel.

Wire protocol (for reference)

Resolved config arrives on scadable/devices/<cn>/cmd/config (retained, QoS 1):

{
  "version": 7,
  "config": {
    "sample_rate_hz": 25,
    "pump_enabled": true,
    "mode": "boost",
    "sys.heartbeat_interval_ms": 30000
  }
}

The device reports the applied version in its heartbeat:

{ "device_id": "SC-a3f8b2c4e6d8", "config_version": 7, "...": "..." }

Managing values (dashboard API)

GET  /api/n/{ns}/config/schema              # declared variables (from the last build)

GET  /api/orgs/{id}/config                  # org-level values
PUT  /api/orgs/{id}/config                  # set org-level values (admin)

GET  /api/n/{ns}/config                     # namespace-level values
PUT  /api/n/{ns}/config                     # set namespace-level values

GET  /api/n/{ns}/devices/{id}/config        # device override values
PUT  /api/n/{ns}/devices/{id}/config        # set device override values

Every PUT is validated against the schema, then the affected devices' maps are recomputed and re-published.

What's NOT in v0.4.0

  • OTA via reserved key. Designed for, implemented later; OTA still uses its existing command path for now.
  • Secrets / encrypted values. Config values are plaintext settings, not credentials.
  • Schema history / rollback. The latest successful build's schema wins.
  • Arbitrary keys outside the schema. If it isn't declared in .scadable/config.yaml, the dashboard won't show it and the build won't accept it.