Channels

A channel is a typed publish destination — a named slot the firmware writes to and the cloud routes from. The libscadable surface exposes channels rather than raw MQTT topics. They're the only MQTT topology you should think about as a customer.

The shape

Channels are declared in .scadable/config.toml. The build pipeline emits a SCADABLE_CH_* enum into scadable_generated.h. Firmware publishes to a channel by ID:

#include "scadable.h"

void publish_temp(double t) {
    char buf[64];
    int len = snprintf(buf, sizeof(buf), "{\"temp\":%.2f}", t);
    scadable_publish(SCADABLE_CH_TEMPERATURE, buf, len, 1);  // qos=1
}

Standalone builds (those that #define SCADABLE_NO_GENERATED) work with raw uint32_t channel IDs.

Why channels (not raw topics)

Older approaches to IoT MQTT exposed topic strings to the firmware: client.publish("device/factory-1/temp", payload). That couples three things the user shouldn't have to think about:

  1. Topic syntax. The user has to know the broker's topology and remember which prefix means what.
  2. Topic discovery on the cloud side. The dashboard can't render a "Live values" panel because it doesn't know which topics exist.
  3. Payload routing. The cloud's historian, audit log, and dashboard all have to topic-string-parse every inbound message to figure out what kind it is.

Channels solve all three. The user picks a channel ID. The library lowers it to a topic. The cloud knows the channel set from the namespace's manifest and routes correctly. The dashboard reads the channel set and auto-renders the right UI.

Outbound publish surface

libscadable v0.3.0 exposes three publish verbs, each typed differently:

VerbUse forExampleCloud routing
scadable_publish(channel, data, len, qos)Generic typed payloads on a declared channelscadable_publish(SCADABLE_CH_HELLO, "world", 5, 1);Bridged to NATS subject events.{ns}.{gw}.publish.{channel}
scadable_metric_set_u32(metric, value) / _f64(metric, value)Numeric time-series telemetryscadable_metric_set_f64(SCADABLE_METRIC_TEMP, 22.5);NATS bridge republishes on events.{ns}.{gw}.data.m{N} for the historian
SCADABLE_LOG_INFO(fmt, ...) (and _DEBUG, _WARN, _ERROR)Structured leveled logsSCADABLE_LOG_INFO("setpoint=%.2f", v);Batched into log envelope, streamed to dashboard

Pick the verb that matches the meaning. The platform handles the routing.

How channels map to MQTT under the hood

{ns}/{gw}/publish/{channel_id}      ← scadable_publish (generic)
{ns}/{gw}/metrics/m{N}              ← scadable_metric_set_*
{ns}/{gw}/logs                      ← SCADABLE_LOG_*  (batched envelope)
{ns}/{gw}/diagnostics/result        ← diagnostic test results

{ns}/{gw}/sys/...                   ← platform-managed, invisible to user
{ns}/{gw}/ota/available             ← OTA notify (delivered as SCADABLE_EVT_OTA_AVAILABLE)
{ns}/{gw}/env/changed               ← env-var nudge (delivered as SCADABLE_EVT_ENV_CHANGED)

{ns} is the namespace ID, {gw} is the device ID. The library subscribes only to the platform topics it needs for its event surface; you never subscribe directly. The broker handles the routing.

Cross-target parity

The same scadable_publish / scadable_metric_set_* / SCADABLE_LOG_* calls work identically on every supported target:

ESP32 / ESP32-S3gateway-linux
scadable_publish(...)mochi-mqtt client over mTLSrumqttc client over mTLS
scadable_metric_set_*typed enum from generated headertyped enum from generated header
SCADABLE_LOG_*batched ring buffer, 5s default flushbatched ring buffer, 5s default flush
SCADABLE_EVT_OTA_AVAILABLEA/B partition swap + bootloader rollbackA/B binary swap + systemd restart

The chip-side lowering differs (chip-friendly C runtime vs. the heavier Linux gateway runtime); the firmware-facing surface is identical.

What about inbound commands?

Status note. The earlier Python-DSL platform had an explicit @on.message(command="X") inbound channel for cloud → device RPCs. libscadable v0.3.0 does not expose that surface. The closest current primitives are:

  • SCADABLE_EVT_ENV_CHANGED — cloud-pushed env-var or secret update; firmware reacts in its event callback
  • SCADABLE_EVT_OTA_AVAILABLE — cloud-pushed firmware availability; library handles the apply automatically
  • Diagnostics (SCD_DIAG) — operator triggers a registered test from the dashboard; firmware runs it and publishes the result

If you need a generic inbound RPC channel for application-level commands, file an issue with your use case. It's tracked but not yet shipped.

Adding a channel

A new channel is a coordinated change across .scadable/config.toml (declaration), the codegen pipeline (enum emission), the cloud manifest (validation), and the dashboard (UI panel if applicable). For application-level channels you control, edit .scadable/config.toml and re-run the build — the codegen handles the rest.

For platform-level new channels (e.g. a new outbound semantic), the change touches the library, the broker bridge, and the dashboard. Don't add a platform channel without all three.

Why not bidirectional channels?

A single events channel that flows both ways is technically possible. We don't do it because:

  • Producers and consumers get tangled. Firmware that both emits AND receives on the same channel has to disambiguate "did I send this or did the cloud?" on every callback.
  • API and UI become harder to reason about. "What can I publish to /events? What do I receive?" — answer requires both perspectives, instead of one.
  • Adding direction-specific concerns later (auth, persistence, retention) means breaking the existing API.

Pick a direction per channel. If you need both directions for the same concept, that's two channels.