Actuate (write to device)

Status (2026-05-11). This page describes today's libscadable v0.3.0 surface and flags what's coming. The earlier Python-DSL platform had a self.actuate(device, register, value) API for protocol-driver writes (Modbus FC6/FC16, GPIO, BACnet WriteProperty). That surface does not exist in libscadable today — protocol-driver writes are still owned by gateway-linux's Modbus driver and not yet exposed through the chip-side library. The roadmap below tracks the gap.

The platform's current device-write story has two halves: runtime configuration (env vars + secrets, live-pushed from the dashboard) and future RPC-style commands (not yet shipped).

What works today: env vars + secrets

The cloud can push configuration changes to a connected device without a firmware redeploy. Set a value in the dashboard, the cloud sends an MQTT nudge, the library refreshes the cache, and your firmware reads the new value on the next scadable_env_get / scadable_secret_get call. You can also subscribe to per-key change callbacks via scadable_on_env_change.

#include "scadable.h"

static void on_env_change(const char *key, const char *new_value, void *user) {
    if (strcmp(key, "POLL_INTERVAL_S") == 0) {
        int interval = scadable_env_get_int("POLL_INTERVAL_S", 30);
        // tell your task to use the new interval
        update_poll_interval(interval);
    }
}

void app_main(void) {
    wifi_up();
    scadable_init(NULL);
    scadable_on_env_change(on_env_change, NULL);
    scadable_connect();
    // ...
}

Use this for setpoints, polling intervals, feature flags, API tokens, anything the operator wants to change without re-flashing. The change propagates within seconds of the dashboard save.

Trade-offs.

  • Env vars cache in RAM only (NOT persisted to NVS — flash dump can't leak them).
  • Secrets cache in a separate RAM-only table that's not visible to env-get callers.
  • Secrets reset to "missing" on reboot until the next refresh succeeds. Don't put critical bootstrap credentials there; those belong in the device cert (which IS in NVS, encrypted at rest by flash encryption when enabled).
  • Updates fire the change callback once per refresh whether the value actually changed or not (diff-only behavior is on the roadmap for v0.4).

What works today: diagnostics as a "write trigger" pattern

If you need an operator-triggered action that runs on the device, model it as a diagnostic test. The cloud "Run check" button dispatches the run; your firmware executes it and publishes the result. The result includes a free-form details field (1 KiB) where you can return whatever you like.

SCD_DIAG(reset_calibration, ctx) {
    DIAG_LOG(ctx, "operator-triggered calibration reset");

    if (!sensor_reset_calibration()) {
        return DIAG_FAIL("sensor refused calibration reset");
    }

    return DIAG_PASS("calibration reset; new offset=%.4f", read_offset());
}

Diagnostics are role-gated cloud-side. Operators with the right permissions trigger them from the dashboard; the dispatch is audited. This isn't a substitute for a real RPC channel, but it covers a useful subset (operator-triggered actions that the firmware acknowledges with a structured result).

Roadmap: RPC-style commands

The earlier platform's @on.message(command="X") decorator gave firmware an inbound RPC surface for cloud-issued commands. That surface is not in libscadable v0.3.0. When it lands (Phase 2 / v0.4 candidate), it will likely look like:

// Roadmap shape — subject to change before ship
SCADABLE_ON_COMMAND("set_setpoint", ctx) {
    double v = scadable_command_arg_f64(ctx, "value");
    update_setpoint(v);
    return SCADABLE_CMD_OK;
}

Cloud-side, the dashboard will auto-render a "Send Command" panel from declared commands in .scadable/config.toml. Role gating (requires_role: "operator") will gate who can issue. Audit will log every dispatch.

Until that ships, model operator-triggered actions as diagnostics; model device configuration changes as env vars.

Roadmap: protocol-driver writes (Modbus, GPIO, BACnet)

For protocol-driver writes (write a value to a Modbus register, set a GPIO pin high, BACnet WriteProperty), today's surface is:

  • gateway-linux has a Modbus driver that supports reads via the legacy declarative config; writes are not exposed to firmware-equivalent customer code.
  • ESP32 / ESP32-S3 don't have a chip-side protocol driver framework yet.

When this lands, the user-facing surface will likely mirror the older platform's self.actuate(device, register, value) shape but expressed in C. Authorization will be cloud-side per command (the role-gating model from the older platform was good and we'll keep it). Idempotency notes from the older API still apply: setpoints and absolute coil writes are safe to retry; counters and pulse outputs are not.

What about cloud-direct writes?

We deliberately don't support a "write directly to register from the dashboard without firmware code" affordance. Reason: every actuate should go through firmware code so the firmware can validate, rate-limit, log, and emit events around the write. A direct cloud-to-driver write bypasses all of that, and ops actions become indistinguishable from autonomous firmware actions in the audit log.

When the RPC and protocol-write surfaces land, the explicit-firmware-handler model will be the only path. If you want a thin actuate path with no firmware logic, write a one-handler command that takes the device + register + value as arguments — that makes the bypass explicit, auditable, and gateable per namespace.

Idempotency notes (preserved from the original architecture)

These guidelines remain true regardless of which API ships:

  • Setpoints (write a holding register or env var to a value): idempotent. Safe to retry.
  • Absolute coil / GPIO writes (set the pin/coil to an absolute new state, not a diff): idempotent.
  • Counters / increments: NOT idempotent. The platform can't help here — the firmware handler must guard.
  • Pulse outputs (open relay for 500ms then close): NOT idempotent. The "open" arrives; the "close" is best-effort.

When in doubt, log the request and the result, and let an ops dashboard show the divergence.

Where to go next