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 }| Field | Required | Notes |
|---|---|---|
type | yes | int, float, bool, string, or enum |
default | yes | Used until a value is set in the dashboard |
min / max | no | Numeric range (int / float) |
values | enum only | Allowed values |
maxlen | string only | Max length in bytes |
description | no | Shown 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:
- Validates the
config:block (types, ranges, duplicate keys, reserved-prefix collisions). A bad declaration fails the build. - 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.
- 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 absentThe 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_mainloop.
Reserved keys (sys.*)
sys.*)The library manages a few keys itself. You don't declare or read these — they're applied automatically in the background:
| Key | Effect |
|---|---|
sys.heartbeat_interval_ms | How often the device heartbeats |
sys.log_flush_interval_ms | How 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.
Updated 3 days ago
