.scadable/ Folder Spec
The .scadable/ folder lives at the root of your repo. SCADABLE syncs it on every webhook event and uses it to drive three things:
- The build.
config.tomldeclares the target chip; the pipeline picks the matching ESP-IDF container. - Codegen.
digital-twin/*.tomlanddiagnostics/*.yamlare compiled intoscadable_generated.h(typed enums for channels, metrics, and tests) andscadable_generated.c(the auto-registration entry point). - Runtime defaults. Connectivity profile, telemetry cadence, log batching, heartbeat interval are baked into the firmware artifact.
Minimum viable
One file, one line:
# .scadable/config.toml
target = "esp32-s3"That's enough. Default broker URL, default cadence presets, default everything. The build pipeline auto-detects your firmware artifact path.
Complete minimal example
my-firmware/
├── .scadable/
│ ├── config.toml
│ ├── digital-twin/
│ │ └── temperature-sensor.toml
│ └── diagnostics/
│ └── sensor-health.yaml
├── main/
│ ├── main.c
│ ├── CMakeLists.txt
│ └── idf_component.yml
├── CMakeLists.txt
└── sdkconfig
Files are explained below.
.scadable/config.toml
target = "esp32-s3" # esp32 | esp32-s3 | esp32-c3 | esp32-c6
# Connectivity profile drives offline detection and reasonable defaults.
[connectivity]
profile = "always_on" # always_on | scheduled | low_power | custom
expected_interval = "60s" # how often we expect this device to phone home
offline_grace = "2m" # extra slack before alerting (default: 10% of interval)
# Telemetry cadence (per-metric override available in digital-twin).
[telemetry]
default_rate = "10s"
# Log batching.
[logs]
batch_interval = "5s" # 0 = realtime; "1d" = once-per-day batch
batch_max_records = 1000
level = "info" # debug | info | warn | error
# Heartbeat (system metrics: uptime, free heap, wifi rssi, etc.)
[heartbeat]
interval = "60s"
include = ["uptime", "free_heap", "wifi_rssi", "battery_voltage"]
# Optional path overrides (auto-detected if omitted).
# [paths]
# digital_twin = "./digital-twin"
# diagnostics = "./diagnostics"Connectivity profile presets
| Profile | expected_interval | offline_grace | default_rate | Use case |
|---|---|---|---|---|
always_on | 60s | 2m | 10s | Mains-powered, always online |
scheduled | 24h | 2h | 5m | Periodic check-in (Starlink-once-a-day) |
low_power | 6h | 30m | 1h | Battery-powered sensors |
custom | required | required | required | Fully manual |
Presets just set defaults you can override field by field.
Two real examples
Always-on monitor:
target = "esp32-s3"
[connectivity]
profile = "always_on"
[telemetry]
default_rate = "100ms" # 10 Hz
[logs]
batch_interval = "1s" # near-realtime; you're watching liveScheduled-online sensor (sleeps most of the day, wakes once):
target = "esp32-s3"
[connectivity]
profile = "scheduled"
expected_interval = "24h"
offline_grace = "2h" # alert only if we haven't heard in 26h
[telemetry]
default_rate = "5m" # capture every 5 min, buffer locally, dump on next check-in
[logs]
batch_interval = "1d" # one big log batch per daySame library, same cloud, two short config files.
.scadable/digital-twin/*.toml
Declares the device classes your firmware exposes. Each TOML file is one class. The build pipeline emits SCADABLE_METRIC_* enums you reference in firmware. Typo'd metric names are a compile error.
# .scadable/digital-twin/temperature-sensor.toml
name = "temperature_sensor"
description = "DS18B20 1-Wire temperature probe"
[[registers]]
name = "temp_celsius"
type = "f32"
unit = "C"
range = [-40.0, 125.0]
rate = "30s" # per-metric override; falls back to [telemetry].default_rate
[[registers]]
name = "battery_voltage"
type = "f32"
unit = "V"
range = [0.0, 5.0]
rate = "1h" # battery doesn't change fast; sample sparselyIn your firmware:
scadable_metric_set_f64(SCADABLE_METRIC_TEMP_CELSIUS, t);
scadable_metric_set_f64(SCADABLE_METRIC_BATTERY_VOLTAGE, v);The enum names follow SCADABLE_METRIC_<UPPER_SNAKE> of the register name. Codegen verifies every reference at link time.
Register fields
| Field | Required | Description |
|---|---|---|
name | Yes | Lowercase snake_case identifier; becomes the enum suffix. |
type | Yes | u8, u16, u32, u64, i32, f32, f64, bool, str. |
unit | No | Display unit in the dashboard (C, V, Pa, RPM). |
range | No | Two-element array. Used for dashboard chart axes and value validation. |
rate | No | Per-metric cadence; overrides [telemetry].default_rate. |
description | No | Shown in the dashboard inventory tooltip. |
.scadable/diagnostics/*.yaml
Declares remote-runnable diagnostic tests. The function name in your source is the test ID. Codegen scans these YAML files, validates each test: field maps to a SCADABLE_TEST(name) declaration in your firmware, and emits scadable_init_diagnostics() for you to call once after scadable_init.
# .scadable/diagnostics/sensor-health.yaml
test: check_sensor_health # function name in your source; this IS the ID
display_name: "Sensor Health" # what operators see in the dashboard
description: "Probes the DS18B20 sensor and validates the reading is in range"
category: sensors # grouping in the dashboard
estimated_duration_secs: 3In your firmware (anywhere in your source tree):
SCADABLE_TEST(check_sensor_health, ctx) {
TEST_LOG(ctx, "starting i2c probe");
float temp; uint32_t latency_ms;
if (ds18b20_read_with_latency(&temp, &latency_ms) != DS18B20_OK)
return TEST_FAIL("sensor unreachable on 1-wire bus");
if (temp < -40.0 || temp > 125.0)
return TEST_FAIL("reading out of range: %.2fC", temp);
return TEST_PASS("temp=%.2fC, read_latency=%ums", temp, latency_ms);
}After scadable_init, call scadable_init_diagnostics() once. Tests are then visible in the dashboard's Checks tab. An operator clicks "Run" → MQTT command dispatches → device executes → result comes back with timing and message.
If your YAML references a test: name that doesn't have a matching SCADABLE_TEST in source, the linker fails loudly. No "wired-up but-not-actually-defined" silent failures.
Three outcomes
| Macro | Dashboard color | Use for |
|---|---|---|
TEST_PASS(fmt, ...) | Green | Everything OK. |
TEST_PASS_WITH_WARN(fmt, ...) | Yellow | Works but needs attention (battery low: 3.2V). |
TEST_FAIL(fmt, ...) | Red, fingerprinted | Broken. Failures are clustered fleet-wide by message fingerprint. |
.scadable/.schema/
JSON Schema files for IDE and AI assistant autocomplete. Generated by scadable init and refreshed on every library upgrade. Don't hand-edit; the build pipeline ignores them.
If you set up your editor to read these schemas (most editors honor a # yaml-language-server: $schema=... comment), you get inline validation for config.toml, digital-twin/*.toml, and diagnostics/*.yaml.
Sync semantics
- The build pipeline reads
.scadable/at the tag SHA, not from your default branch. Whatever was committed at that tag is what gets baked into the artifact. - Changes outside
.scadable/(your firmware code) trigger a rebuild on next tag. Changes inside.scadable/also trigger a rebuild on next tag, plus a dashboard sync of the digital-twin and diagnostics catalogs. - There is no "live edit" of
.scadable/from the dashboard. The repo is the source of truth.
Gotchas
- TOML floats need a decimal point.
range = [-40, 125]is a list of integers;range = [-40.0, 125.0]is what you want for anf32register. - Enum names are derived, not declared. Register
name = "temp_celsius"becomesSCADABLE_METRIC_TEMP_CELSIUS. Renaming a register is a breaking change for existing firmware that references the old enum. profile = "custom"requires all four interval fields. Otherwise the build pipeline rejects with aSCAD_E0xxxerror pointing at the missing field.- Diagnostic file name is cosmetic.
sensor-health.yamlvsfoo.yamldoesn't matter. Thetest:field is the only thing wired into codegen.
Updated 5 days ago
