C API
libscadable v0.3.0 introduces a typed diagnostic surface (SCD_DIAG, scadable_diag_result_t) that replaces the v0.1/v0.2 SCADABLE_TEST macros. The legacy macros still compile — old code keeps working — but new code should use the v0.3.0 surface so the per-type config block in your YAML actually has a place to land.
The result type
typedef enum {
TEST_RESULT_PASS = 0,
TEST_RESULT_PASS_WITH_WARN = 1,
TEST_RESULT_FAIL = 2,
TEST_RESULT_TIMEOUT = 3, // library aborted after timeout_secs
TEST_RESULT_ERROR = 4, // not registered, panic, or bad arg
TEST_RESULT_TYPE_NOT_SUPPORTED = 5, // cloud asked for a type this firmware can't run
} scadable_test_status_t;
#define SCD_DIAG_DETAILS_CAP 1024
typedef struct {
scadable_test_status_t status;
uint32_t duration_ms; // library auto-measures
char message[256]; // short summary
char details[SCD_DIAG_DETAILS_CAP]; // free-form, optional
const char *output_log; // NULL in v1; reserved for streaming
} scadable_diag_result_t;message is a printf-formatted summary (one line, ideally under 80 chars — what the dashboard list rows show). details is 1 KiB of free-form bytes. v1 customers use details however they like (multi-line context, JSON fragments, sensor dumps); future v1.1+ types will document a structured shape per type. output_log is reserved for streaming output in v1.1+; in v1 it's always NULL and the per-call ctx log buffer is published as the v1 envelope's log field.
duration_ms is filled by the library. Don't set it yourself.
Writing a diagnostic
Use the SCD_DIAG macro to declare the function and the DIAG_PASS / DIAG_FAIL family to return.
#include "scadable.h"
SCD_DIAG(motor_health, ctx) {
DIAG_LOG(ctx, "reading vibration");
float rms = read_vibration();
if (rms > 4.5f) {
return DIAG_FAIL("rms too high: %.2fg", rms);
}
DIAG_LOG(ctx, "reading bearing temp");
float t = read_bearing_temp();
if (t > 90.0f) {
return DIAG_PASS_WITH_WARN("temp marginal: %.1fC, rms=%.2fg", t, rms);
}
return DIAG_PASS("rms=%.2fg, temp=%.1fC", rms, t);
}The five return helpers cover every status:
| Macro | Status |
|---|---|
DIAG_PASS(fmt, ...) | TEST_RESULT_PASS — green check in the dashboard. |
DIAG_PASS_WITH_WARN(fmt, ...) | TEST_RESULT_PASS_WITH_WARN — yellow warning, fingerprinted fleet-wide. |
DIAG_FAIL(fmt, ...) | TEST_RESULT_FAIL — red, fingerprinted, triggers auto-rollback if run_after_ota: true. |
DIAG_TIMEOUT(fmt, ...) | TEST_RESULT_TIMEOUT — you almost never return this manually; the library returns it on timeout_secs overrun. |
DIAG_ERROR(fmt, ...) | TEST_RESULT_ERROR — return this if you can't run the test at all (missing dependency, bad config). |
The DIAG_LOG(ctx, fmt, ...) macro buffers a log line that ships with the result envelope. The dashboard renders the buffer in the run drawer. Useful for long-running tests where the operator wants to see what the test was doing when it failed.
Registration
The build pipeline emits scadable_init_diagnostics() from your YAML. It calls scadable_register_diagnostic() once per declared diagnostic, binding each id: to the matching C symbol from your YAML's function.symbol: field.
void app_main(void) {
nvs_flash_init();
wifi_up();
scadable_init(NULL);
scadable_init_diagnostics(); // codegen-emitted; registers everything
scadable_connect();
/* normal app loop */
}Order matters: scadable_init_diagnostics() must come after scadable_init() (which sets up the registration table) and can come before or after scadable_connect() — registration is local-only.
If you're building standalone (SCADABLE_NO_GENERATED defined), the codegen seam is empty. Register manually:
scadable_register_diagnostic("motor_health", "function", motor_health);
scadable_register_diagnostic("network_roundtrip", "function", network_roundtrip);The type_str argument must match the YAML type: field — passing "function" is the only value v1 accepts. Future-typed diagnostics will require a libscadable bump.
Manual one-shot run
For offline customer-driven sweeps (no cloud round-trip), call:
scadable_err_t scadable_run_diagnostic(const char *id, const char *run_id);
scadable_err_t scadable_run_all_diagnostics(const char *run_id);Pass any string for run_id if you want to correlate the result with your own logging. The cloud-triggered manual-run path mints a ULID and passes it through; standalone callers can pass NULL and the library will mint one.
Behavior when things go wrong
| Situation | Behavior |
|---|---|
YAML declares id: motor_health but firmware doesn't define motor_health | Codegen emits extern scadable_diag_result_t motor_health(...) then a registration call. If you didn't write the function, the linker fails loudly at firmware build time, before flash. |
scadable_register_diagnostic called twice with the same id | Second call wins; first is silently overwritten. Codegen never generates this; manual customers who do it get a debug log. |
| Firmware registers an id but the cloud catalog no longer declares it | The chip will still run it (registration is local). The cloud accepts the result but logs a warning. The dashboard won't show a button for it (no catalog row). |
Cloud sends diagnostic.run for an id this firmware doesn't have registered | Chip publishes TEST_RESULT_ERROR with message "unknown diagnostic id". The dashboard surfaces "not implemented on this firmware version" with a hint to bump libscadable or fix the YAML. |
Cloud sends diagnostic.run for an id whose type: this libscadable version doesn't support | Chip publishes TEST_RESULT_TYPE_NOT_SUPPORTED. The dashboard surfaces a badge showing the minimum required libscadable version. |
| Test function panics or watchdog-resets the chip | ESP32 boot validation reverts to the prior firmware partition. No "diagnostic killed the chip permanently" failure mode. |
Test function exceeds timeout_secs | Library publishes TEST_RESULT_TIMEOUT and moves on. Per-test cooperative timeout in v1 — if your fn is in a tight CPU loop with no sleep, the timeout is best-effort. v1.1 plans per-test FreeRTOS task isolation for hard kill. |
Arduino + ESP-IDF code parity
The same SCD_DIAG body works in both environments — it expands to a function declaration. ESP-IDF setup uses app_main; Arduino uses setup/loop:
// ESP-IDF main.c
void app_main(void) {
nvs_flash_init();
wifi_up();
scadable_init(NULL);
scadable_init_diagnostics();
scadable_connect();
while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); }
}// Arduino .ino
#include "scadable.h"
void setup() {
Serial.begin(115200);
wifiUp();
scadable_init(nullptr);
scadable_init_diagnostics();
scadable_connect();
}
void loop() { delay(1000); }For Arduino projects, place your SCD_DIAG(...) { ... } definitions in a .cpp file (or in the .ino itself — Arduino concatenates everything before compilation).
Legacy v0.1/v0.2 surface
The pre-v0.3.0 macros are still defined and still work. New code should use the typed surface, but if you have an existing codebase, you don't have to migrate.
// Legacy — still compiles, still publishes (envelope v1)
SCADABLE_TEST(check_sensor_health, ctx) {
TEST_LOG(ctx, "starting probe");
if (problem) return TEST_FAIL("sensor unreachable");
return TEST_PASS("temp=%.2f", t);
}The cloud parses both envelope versions. Legacy chips show "diagnostic v1 (legacy)" in the dashboard run drawer. Migration when you're ready: rename SCADABLE_TEST to SCD_DIAG, TEST_PASS / TEST_FAIL to DIAG_PASS / DIAG_FAIL, and you're on the typed surface.
See also
- Config schema: the YAML side this API binds to.
- Post-OTA verification: how diagnostics drive release status.
- API reference: the rest of
scadable.h.
Updated 1 day ago
