.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:

  1. The build. config.toml declares the target chip; the pipeline picks the matching ESP-IDF container.
  2. Codegen. digital-twin/*.toml and diagnostics/*.yaml are compiled into scadable_generated.h (typed enums for channels, metrics, and tests) and scadable_generated.c (the auto-registration entry point).
  3. 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

Profileexpected_intervaloffline_gracedefault_rateUse case
always_on60s2m10sMains-powered, always online
scheduled24h2h5mPeriodic check-in (Starlink-once-a-day)
low_power6h30m1hBattery-powered sensors
customrequiredrequiredrequiredFully 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 live

Scheduled-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 day

Same 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 sparsely

In 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

FieldRequiredDescription
nameYesLowercase snake_case identifier; becomes the enum suffix.
typeYesu8, u16, u32, u64, i32, f32, f64, bool, str.
unitNoDisplay unit in the dashboard (C, V, Pa, RPM).
rangeNoTwo-element array. Used for dashboard chart axes and value validation.
rateNoPer-metric cadence; overrides [telemetry].default_rate.
descriptionNoShown 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: 3

In 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

MacroDashboard colorUse for
TEST_PASS(fmt, ...)GreenEverything OK.
TEST_PASS_WITH_WARN(fmt, ...)YellowWorks but needs attention (battery low: 3.2V).
TEST_FAIL(fmt, ...)Red, fingerprintedBroken. 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 an f32 register.
  • Enum names are derived, not declared. Register name = "temp_celsius" becomes SCADABLE_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 a SCAD_E0xxx error pointing at the missing field.
  • Diagnostic file name is cosmetic. sensor-health.yaml vs foo.yaml doesn't matter. The test: field is the only thing wired into codegen.