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:

MacroStatus
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

SituationBehavior
YAML declares id: motor_health but firmware doesn't define motor_healthCodegen 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 idSecond 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 itThe 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 registeredChip 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 supportChip publishes TEST_RESULT_TYPE_NOT_SUPPORTED. The dashboard surfaces a badge showing the minimum required libscadable version.
Test function panics or watchdog-resets the chipESP32 boot validation reverts to the prior firmware partition. No "diagnostic killed the chip permanently" failure mode.
Test function exceeds timeout_secsLibrary 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