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:
- Topic syntax. The user has to know the broker's topology and remember which prefix means what.
- Topic discovery on the cloud side. The dashboard can't render a "Live values" panel because it doesn't know which topics exist.
- 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:
| Verb | Use for | Example | Cloud routing |
|---|---|---|---|
scadable_publish(channel, data, len, qos) | Generic typed payloads on a declared channel | scadable_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 telemetry | scadable_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 logs | SCADABLE_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-S3 | gateway-linux | |
|---|---|---|
scadable_publish(...) | mochi-mqtt client over mTLS | rumqttc client over mTLS |
scadable_metric_set_* | typed enum from generated header | typed enum from generated header |
SCADABLE_LOG_* | batched ring buffer, 5s default flush | batched ring buffer, 5s default flush |
SCADABLE_EVT_OTA_AVAILABLE | A/B partition swap + bootloader rollback | A/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 callbackSCADABLE_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 resultIf 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.
Updated 2 days ago
