Edge Relay reference
Added in 4.6.0. cmd/edge-relay is a second binary, not a mode of the
Gateway: a poll-and-forward Sparkplug B edge node with no database, no web
UI, no auth and no write path. It exists to run on a Pi or a small industrial
PC at the edge, forwarding everything it reads to a full Gateway running
somewhere with a real database, over the same Sparkplug B publisher
(internal/edgepublish) 4.5.0 added.
4.5.0’s own note said this directly: edge publish existed as a feature you could turn on inside the full Gateway process, “not yet the standalone lighter Edge Relay edition.” This is that edition.
What it is not
Section titled “What it is not”Not a smaller Gateway with fewer options. It is a different binary with a
different, much smaller set of dependencies. cmd/edge-relay never imports
internal/store (PostgreSQL/TimescaleDB), internal/api (the web UI and
auth), internal/mcpserver, internal/mcpclient, internal/automation or
internal/entity (the entity registry).
That is enforced by what the binary’s own main.go imports, not by a flag, there is no HOTLOOP_EDGE_RELAY_MODE switch buried in the Gateway that turns
features off. The resulting binary is roughly a third of the size of the
Gateway’s (12.5 MB vs. 33.9 MB on amd64, 11.7 MB vs. 31.6 MB on arm64,
CGO_ENABLED=0, both stripped, measured from the images this repository’s
Containerfiles build). And it is checked:
TestEdgeRelayDoesNotImportGatewayOnlyPackages asks go list -deps what the
binary actually links, and fails if any of those packages creeps in through a
shared one.
Not able to serve a UI, take a write, or answer an MCP tool call. All three need something this binary deliberately does not have: an authenticated identity to attribute the action to. An edge relay decides nothing and confirms nothing back to a human; it reads and forwards.
Not configured through a database, and it has no config API of its own. Devices and tags come from a local JSON file read at startup, or from a file the Gateway pushes over the broker, which applies live (see Config push). Nothing listens on the relay for a new config: the only way in is the broker connection it already dials out on.
How it fits together
Section titled “How it fits together”Internally this reuses the Gateway’s actual polling engine
(internal/runtime), not a reimplementation of it: the same worker,
reconnect/backoff, and poll-loop code that has run in production as part of
the Gateway runs here unchanged. What differs is where configuration and
history go. internal/runtime.Manager talks to a Backend interface, EnabledDevices, Tags, WriteHistory, and so on, that internal/store.Store
already satisfied without any change of its own. internal/edgerelay.Backend
is the other implementation: devices and tags come from the JSON file instead
of Postgres, history-writing is a no-op (this binary forwards instead of
storing), and the system-log calls the Gateway would send to a database table
become a structured log line instead.
Everything downstream of a poll, the driver, the tag bus, the Sparkplug
publisher, the on-disk forward queue, is the exact same code the Gateway
uses when HOTLOOP_EDGE_PUBLISH_ENABLED is set. See docs/EDGE-PUBLISH.md for
what that publisher actually does: births, sequence numbers, buffering
through an outage, replay on reconnect. None of that changed here; this
binary is a different way of arriving at the same Offer() call.
The device file
Section titled “The device file”{ "devices": [ { "id": "press-01", "name": "Press 1", "protocol": "opcua", "address": "opc.tcp://press-01.local:4840", "enabled": true, "pollIntervalMs": 1000 } ], "tags": [ { "deviceId": "press-01", "name": "Zone1_Temp", "address": "ns=2;s=Zone1.Temp", "dataType": "float", "enabled": true } ]}A tag takes everything the Gateway’s tags do, noData included: a list of
raw values that mean “no measurement” (a recorder’s 0x8005), each with a
reason, so a dead channel reads bad on the relay and in the Sparkplug stream
instead of as a number. See No-data values.
Same Device and Tag JSON shapes the Gateway’s own API accepts, see
internal/model/device.go, validated with the identical rules (a device
needs a known protocol, a tag needs a device that exists), plus two checks a
single file can get wrong that a database’s own constraints would have
caught: a device id declared twice, and a tag pointing at a device that was
never declared.
All seven of the Gateway’s protocol drivers are compiled in: OPC UA, Modbus, MQTT/Sparkplug B, EtherNet/IP, S7comm, MTConnect and HTTP. Nothing about the device file format is edge-relay-specific.
Discovery (4.18.10)
Section titled “Discovery (4.18.10)”A relay can listen to an MQTT broker for devices that announce themselves, the way the Gateway does (see DISCOVERY.md), and read and forward whatever it hears. Name the brokers in the device file:
{ "devices": [], "tags": [], "discovery": [ { "id": "yard", "broker": "10.0.0.5:1883", "username": "hotloop", "password": "...", "prefix": "homeassistant", "listenOnly": true, "tasmota": true, "shelly": true } ]}id is 1 to 63 lowercase letters, digits and -, and not embedded. broker,
username, password, useTls and insecureSkipVerify are what the
Gateway’s Settings, MQTT take, checked by the same code. prefix defaults to
homeassistant, and tasmota and shelly to on. A file that names no broker
changes nothing about how a relay runs.
What it hears becomes devices and tags, made by the same code the Gateway
runs (the homeassistant/ format, Tasmota’s own, Shelly’s statuses), and they
are polled and forwarded over Sparkplug like any other. A device’s id is made
from the broker’s id and who the device says it is, the same every time
(disc-<identity>-<hash>), so a relay that restarts names its metrics the
same. A device appears a couple of seconds after it announces, a withdrawn one
leaves the node’s birth in a rebirth, and neither costs the Sparkplug session or the forward queue. A
pushed file that adds or removes a broker applies live too.
Nothing it discovers can be armed. Not because a check says so. Every tag discovery makes is read-only, as it is on the Gateway; every device it makes is not accepted; there is nobody on a relay to accept one; and a relay has no write path at all, so a command to a discovered point, or to any point, is refused before it gets near a device. The test for it asks a relay with writes allowed to switch a discovered pump, and checks the pump’s command topic stayed quiet.
A device you can command reaches a relay the other way round: you accept it on a Gateway, arm the points you mean to, and push the file. When a file has a device the relay also hears (the same broker id, or the same address, and the same identity), the file’s wins and the discovered copy is not made, so one device is never forwarded twice.
listenOnly is on unless you turn it off, and then the relay publishes nothing
to that broker: not the birth message that makes a publisher announce itself
again, not a Shelly’s status_update, not a Tasmota’s power query. A device
that only speaks when it changes reads unknown until it does. Turn it off
and those are the only things it sends, to the topics DISCOVERY.md names.
A relay keeps nothing about what it heard. The homeassistant/ and Tasmota
configs are retained on their broker and come back when it starts; a Shelly
says its statuses whenever something changes, so a device that has been quiet
since the relay started is not there until it speaks. That, and not having a
database to remember a device accepted in, is the difference from the Gateway.
/status says, under discovery, for each broker: whether it is listening and
connected, how many messages it received, dropped and refused, and how many
problems are on it, and the number of devices and tags discovery made. It does
not say where a broker is, or what topic a problem was on.
Credentials in the device file (4.15.0)
Section titled “Credentials in the device file (4.15.0)”The relay’s OPC UA driver is the Gateway’s, so a device can carry an OPC UA
username and password (and the security options in
PROTOCOLS.md) in its config
block, and so can a device of any other protocol (MQTT and HTTP passwords, HTTP
headers, credentials in an address). Those are in the device file in the clear, and
the relay has no API to hide them behind (the API redaction added to the Gateway
in 4.15.1 does not apply to it; see
AUTH.md), so where the file
lives matters:
- In the Helm chart, use
devicesSecret. Devices set in the chart’sdevicesandtagsvalues are rendered into a ConfigMap, which anyone allowed to read ConfigMaps in the namespace can read, and they are kept in the Helm release record. For devices with credentials, put the whole device file in a Secret under the keydevices.jsonand name it indevicesSecret; the Secret is mounted in place of the ConfigMap, which is then not created. - A pushed device file carries them too. A file pushed with
POST /api/fleet/{id}/configis published, retained, to an MQTT topic, so the broker must carry TLS and restrict who may readhotloop/edge-relay-config/#, or whoever can subscribe to it can read every credential in it. The relay writes a pushed file with mode0600, on its data volume (/data/pushed-devices.jsonin the chart and the Quadlet unit), so it’s on the PVC too, not in a Secret. - A secured OPC UA device keeps a certificate and a trust store. They live
under
/data/opcua-client-pki(HOTLOOP_OPCUA_CLIENT_PKI_DIR), on the same persistence volume as the forward queue. With the chart’s persistence off that’s an emptyDir, so every new pod makes a new certificate and every secured server has to trust it again (see “Persistence off” below); an anonymous device on an unsecured channel doesn’t care. Approving a server’s certificate is the same move as on the Gateway,rejected/totrusted/, done on the relay’s volume.
Remote commands (4.7.0)
Section titled “Remote commands (4.7.0)”Two Sparkplug NCMD commands, both over the same connection this relay already dials out on, no inbound connection required:
-
Rebirth (
Node Control/Rebirth), since 4.5.0, viainternal/edgepublish. -
Restart (
Node Control/Reboot), since 4.7.0: the relay publishes its own graceful NDEATH and exits, relying on its supervisor (a Quadlet unit’sRestart=always, or a Deployment’s defaultrestartPolicy: Always) to bring it back up. -
Update (
Node Control/Update): an operator approving the image update a relay is holding for. It only does anything to a relay set to hold updates; see Updating a relay.
Nothing on the relay’s own side decides who is allowed to send these, see
docs/FLEET.md for the Gateway-side fleet manager that sends them, and the
broker ACL requirement that is the actual authorization boundary.
Updating a relay
Section titled “Updating a relay”A relay is not updated by the Gateway. It can’t be: the Gateway has no way onto the box, and the relay can’t replace its own container, so “remote update” means different things for the two ways to install one, and this page says which, because a promise the platform can’t keep is worse than none.
The rule is the one every HotLoop box follows: track :stable, never pin.
A relay pinned to 4.17.0 is exactly as old as the day somebody installed it,
and nobody remembers to go back to a plant floor and bump it. So the image a
relay follows is ghcr.io/hotloop-io/hotloop-edge-relay:stable, and every
release moves that tag. What differs is who restarts the relay onto it.
Quadlet (a Pi or a small industrial PC): it updates itself
Section titled “Quadlet (a Pi or a small industrial PC): it updates itself”The unit in deploy/quadlet follows :stable
and carries the label Podman’s auto-update looks for. Turn the timer on once
per box:
sudo systemctl enable --now podman-auto-update.timerWhen the timer fires, Podman pulls :stable, and if the digest changed it
restarts the unit on the new image. Three things make that safe to leave
alone, and all three were proven by running them, not by reading about them:
- A relay that can’t come back is put back. The unit has a healthcheck
(
/edge-relay --healthcheck, because the image has no shell or curl to run one) and tells systemd it isn’t up until that passes. If the new image never does, Podman moves the previous image back under:stableand starts that one. A release that can’t start costs a few minutes on one box, not a relay that stays down. On Podman 4.9 (Ubuntu 24.04)Notify=healthyalone does not do this: it is read as plaintrue, the relay is called up the moment its container starts, and Podman reports the update a success while the relay is dead. The unit sets--sdnotify=healthyexplicitly for that reason. - The Gateway sees every update. A relay puts its version and the digest of its image in every birth. The Gateway’s fleet list shows both per relay, and writes a line to the system log when one changes: what it was, what it is now, and both digests. That is the audit trail, and it covers an update from any cause, including an operator on the box.
- An update leaves the data alone. The forward queue and a pushed device file live on the data volume, which a new image doesn’t touch. What is lost is the polling while the relay restarts, a few seconds at most.
The digest a relay reports is what podman image inspect --format '{{.Digest}}' prints on that box, which is the thing auto-update follows.
Podman can know one image by more than one digest (it lists them under
RepoDigests) when a registry stores a different manifest format than the one
it works with, so compare it the same way on every relay, not against a
registry’s header.
Quadlet, holding updates until an operator approves (opt-in)
Section titled “Quadlet, holding updates until an operator approves (opt-in)”Some sites would rather say when: a relay on a line that can’t blink during a
shift. That is edge-relay-hold.conf,
a systemd drop-in, and it is never the default. With it the relay reports
"updateMode": "approve", the timer is off, and nothing updates until an admin
sends POST /api/fleet/{id}/update from the Gateway. The relay then exits with
status 75, systemd starts it again on the image it has, and a separate job
waits for it to be healthy and runs podman auto-update: the new image if
there is one, with the same rollback, and a relay that blinked for nothing if
there isn’t. Proven with a real update held for 30 seconds while the relay ran
untouched, then taken on approval.
Know what it costs. The timer has to be off for the whole machine, because
Podman’s auto-update acts on every labeled container and can’t be told to skip
one; a box that has to run it for other things can’t hold this relay. And the
Gateway can’t tell you an update is waiting, only what each relay runs now, so
compare the fleet list with what :stable is before you approve. An approval
carries no image: the relay can only be told to update to what its own unit
follows.
Helm: you run helm upgrade
Section titled “Helm: you run helm upgrade”A pod can’t replace itself, and a Deployment is Helm’s to change, so there is
no remote update for the chart. The chart’s image.tag defaults to its
appVersion, which is the release, so:
helm repo updatehelm upgrade edge-relay hotloop/hotloop-edge-relay --reuse-valuesrolls the pod onto the new release. The chart’s strategy stops the old pod
before it starts the new one (maxSurge: 0, because the forward queue is a
single-writer file on one volume), so there is a gap while it comes up. The Gateway still sees the new version in the
relay’s next birth and logs the change. Under Helm a relay doesn’t report an
image digest (a container can’t see its own, and Kubernetes doesn’t hand it
down), only its version. Node Control/Update does nothing here and the relay
says so in its log; Helm is the approval.
What is not here, and why
Section titled “What is not here, and why”- Signed images and a digest allowlist. The Gateway can’t enforce either
on a box it has no way onto, and a check the relay runs on itself is one
that a compromised relay skips. Podman can verify signatures at pull time
(
policy.json), which is the place that works; nothing here sets that policy up for you, and:stableis trusted the way any tag is. - Staged rollout. Every relay on a box follows the same tag on its own timer, so a fleet updates over the timer’s spread, not in waves, and the rollback above is what limits a bad release. To stage deliberately, hold the sites that should wait with the hold drop-in and approve them in the order you want.
Config push (4.8.0)
Section titled “Config push (4.8.0)”A new device file can be pushed on a separate, plain (non-Sparkplug),
retained MQTT topic. See docs/FLEET.md’s “Config push” section for the
Gateway’s side. Up to 4.17 a push only staged the file and the relay kept
polling the old list until something restarted it. That made every change to
what a relay polls a restart of the relay, its broker session and every
device session it held, on a box that is supposed to sit there for months
without being touched. Now a push applies the moment it lands.
- Pushed while this relay is running: the bytes are validated with the
same rules
LoadDeviceFileapplies at boot, saved toHOTLOOP_EDGE_RELAY_PUSHED_FILE(or overHOTLOOP_EDGE_RELAY_DEVICES_FILEwhen that isn’t set; see below), and applied live. Saved first, on purpose: a list that ran but was never saved would quietly revert at the next boot. - A bad push is refused and the old list keeps running. One that won’t
parse, won’t validate (a duplicate device, a tag on a device nobody
declared, an unknown protocol) or can’t be saved changes nothing: not the
running devices, not the file on disk. The log says why, and so does
/statusunderconfig.rejected, which is where the Gateway’s fleet screen picks it up. - Pushed while this relay was offline: nothing is there to receive it
until the next boot, at which point the relay gives a retained push a
couple of seconds to arrive before it loads its local device file (see
applyPushedConfigincmd/edge-relay/main.go), so it boots straight into it. A push that shows up after those two seconds applies live like any other. - The broker hands the retained push back on every reconnect. The relay knows the file it’s running by its sha256, so the same bytes arriving again change nothing.
What a live apply keeps, and what it changes
Section titled “What a live apply keeps, and what it changes”The relay works out what changed between the file it’s running and the new one, device by device, and touches only that:
- A device that didn’t change keeps its session. Same TCP connection to the PLC, same OPC UA subscription, no gap in its readings.
- A changed device (its address, protocol, poll interval, timeout, config block or tags) is reconnected with the new definition.
- A new device is connected and starts polling.
- A removed device (or one now
"enabled": false) is disconnected. - The Sparkplug session stays up. No NDEATH, no reconnect, same bdSeq, and the forward queue isn’t touched. A reload during a broker outage leaves every buffered reading in the queue; they drain when the broker is back, the same as without a reload.
On the wire, per Sparkplug B. The relay is one edge node and every tag is a
node metric named device/tag, so the node’s metric set is what changes, and
Sparkplug gives a node exactly one way to change it: a new NBIRTH, which
replaces the last one whole. So:
- Metrics that went away (a removed device, a removed or disabled tag)
are sent once more in an NDATA as null, then the node rebirths without
them: same session, same bdSeq, seq back to 0, the same rebirth a
Node Control/Rebirthgets. The null is there because a host is allowed to keep the last value of a metric a new birth leaves out, and the Gateway’s own Sparkplug host does exactly that. Without the null, a valve you took off the relay would sit on its last good reading forever, looking healthy. - Metrics that are new go into a birth on their device’s first poll, the same way every metric does at boot. A birth never names a metric before there’s a reading behind it.
- Readings of a removed tag still in the forward queue are dropped when the queue drains, with a log line counting them. The node’s birth no longer carries that metric, and Sparkplug has no legal way to send one it doesn’t, so sending them would mean an alias no host was ever told about.
Editing the local device file by hand still takes a restart: the relay reads
it at boot and when a push is cleared, and doesn’t watch it. Under the chart
that’s what a helm upgrade changing devices does anyway.
Where a pushed file lives, and which file wins
Section titled “Where a pushed file lives, and which file wins”Both supported installs mount the local device file read-only: the chart from
a ConfigMap (or Secret), the Quadlet unit as a :ro bind, and both on a
read-only root filesystem. Up to 4.16.0 the relay wrote a push right next to
that file, so on either install every single push died with
a pushed config was rejected; keeping the current one and continuing to runerror="write /etc/edge-relay/devices.json.tmp: open /etc/edge-relay/devices.json.tmp: read-only file system"and the relay kept polling the old list. The Gateway said config pushed, the
restart went through, and nothing changed. That’s fixed by giving the push its
own home:
HOTLOOP_EDGE_RELAY_PUSHED_FILEis where a push is written. The chart sets it to/data/pushed-devices.jsonon the PVC (pushedDevicesFilein values.yaml), and the Quadlet unit sets the same path on its named data volume. Both outlive the container, and the PVC outlives a reschedule. That last part is deliberate: an emptyDir would be writable too, but it’s gone the moment the pod moves, and a relay that silently goes back to its old device list after a node drain is the same bug with extra steps.- The pushed file wins. At boot the relay loads the pushed file if it
exists, and the local one only if it doesn’t. That holds after a restart,
after a reschedule, after the broker has lost the retained push (a
Mosquitto restart without persistence), and after a
helm upgradethat changesdevicesortags. The push is the newer, deliberate change, and the broker would hand the retained copy straight back at the next boot anyway, so letting the local file win would only last one restart. The first lines of the log say which file it booted from:booting from the pushed device file; the local one is ignored until the push is cleared. - Going back to the local file (the chart’s
devices, or/etc/edge-relay/devices.jsonunder Quadlet) isDELETE /api/fleet/{id}/configon the Gateway. The delete clears the retained push on the broker, so no later boot applies it, and a relay that’s connected at the time loads its local file, deletes its pushed file, logsthe config push was cleared; the local device file takes over, and applies the local file live, the same way a push applies. If the local file doesn’t load, the relay keeps the pushed file running and on disk and says so in the log and in/status: deleting it first would leave a relay that boots into a broken file at its next restart. Fix the local file and send the delete again. Do it while the relay is online. A clear isn’t retained, so a relay that was offline for it never hears about it and keeps its pushed file; bring it up and send the delete again. It’s safe to repeat. - A pushed file that exists but won’t load (a disk fault, or a later release with stricter rules) is logged as an error and the relay boots from the local file rather than staying down over it.
- With
HOTLOOP_EDGE_RELAY_PUSHED_FILEunset, a push overwritesHOTLOOP_EDGE_RELAY_DEVICES_FILEitself, the 4.8.0 behavior. That’s only any good for a bare binary with a writable device file, and then there’s no separate copy for a clear to drop.
If a push “didn’t take”, look at the relay’s log before anything else. Either it says which file it booted from, or it says why the push was rejected.
The HTTP surface
Section titled “The HTTP surface”Two endpoints, both unauthenticated, both read-only:
GET /healthz, the process is up. Nothing more; a device being disconnected is not this process being unhealthy.GET /status, a snapshot: device counts by connection state, tag count, the forward queue’s current depth, the compiled-in protocol list, anduplink: whether the broker is connected, whether a replay is running, and what the forward queue holds, weighs on disk and has dropped at its bound (see docs/EDGE-PUBLISH.md, “When the uplink is down”). No tag values, no device addresses, no configuration, this is a monitoring surface, not a smaller copy of the Gateway’s dashboard.version,imageDigestandupdateModesay what build it is and whether it holds updates. It also says which device file is running, underconfig:source(localorpushed),path, its sha256digest,appliedAt, andrejected, the last file the relay refused and why, until a push after it applies. The digest, not the file: the Gateway can match it against what it pushed without the file’s addresses and credentials going out on an unauthenticated port. With adiscoverybroker in the file it also says, underdiscovery, how each one is doing and how many devices it found (see Discovery).
Deliberately no auth in front of either: an edge relay has no user database,
no session store and no identity system to build one from, and adding a
single shared token would be theatre rather than security for two endpoints
that expose nothing sensitive. Run it behind whatever network perimeter the
device itself sits behind, the same expectation the Gateway’s own
auth.insecure mode already documents for a deployment behind a trusted
proxy.
Configuration
Section titled “Configuration”| Variable | Default | Details |
|---|---|---|
HOTLOOP_EDGE_RELAY_DEVICES_FILE |
n/a | Required. Path to the device file above |
HOTLOOP_EDGE_RELAY_PUSHED_FILE |
unset | Where a pushed device file is kept, and it wins over the one above while it exists. Must be writable and persistent; the chart and the Quadlet unit use /data/pushed-devices.json. See Where a pushed file lives |
HOTLOOP_EDGE_RELAY_LISTEN_ADDR |
:8081 |
The /healthz and /status server |
HOTLOOP_EDGE_RELAY_UPDATE_MODE |
auto |
auto leaves updating to the supervisor and ignores an approval from the Gateway; approve holds updates until one arrives. See Updating a relay |
HOTLOOP_IMAGE_DIGEST |
unset | The digest of the image this relay runs from, as its supervisor knows it. The Quadlet unit sets it before every start. Reported in /status and every birth; empty is fine |
HOTLOOP_LOG_LEVEL |
info |
|
HOTLOOP_POLL_INTERVAL |
5s |
Default poll interval; a device’s own pollIntervalMs overrides it |
HOTLOOP_MIN_POLL_INTERVAL |
100ms |
Floor enforced on every device, regardless of its own setting |
HOTLOOP_POLL_TIMEOUT |
10s |
Default per-poll timeout; a device’s own timeoutMs overrides it |
HOTLOOP_MAX_CONCURRENT_POLLS |
32 |
Devices that may be mid-poll at once |
HOTLOOP_RECONNECT_MIN / HOTLOOP_RECONNECT_MAX |
1s / 60s |
Backoff bounds between reconnect attempts, jittered |
HOTLOOP_RING_SIZE |
120 |
Samples per tag kept for /status-adjacent diagnostics |
HOTLOOP_OPCUA_CLIENT_PKI_DIR |
/data/opcua-client-pki |
The OPC UA driver’s certificate and its trusted/ and rejected/ stores; a device’s own pkiDir overrides it. Only a secured OPC UA device touches it |
Identical variable names and defaults to the Gateway’s own RuntimeConfig, see its “Polling” section in docs/DEPLOYMENT.md for the same settings
expressed as Helm values, because both binaries load this config through
the same config.LoadRuntimeConfig() function, not two copies that could
drift apart.
Plus every HOTLOOP_EDGE_PUBLISH_* variable docs/EDGE-PUBLISH.md documents,
with one difference: HOTLOOP_EDGE_PUBLISH_ENABLED is ignored and forwarding is
always on, publishing what it reads is this binary’s entire purpose, not an
opt-in feature the way it is on the Gateway.
What has and has not been verified
Section titled “What has and has not been verified”Tested end to end against a real Mosquitto broker and a real polled device (an
httptest HTTP endpoint standing in for the plant): a reading crosses the
poll loop, the tag bus, and the Sparkplug publisher, and arrives at a plain
MQTT subscriber as a decoded, correctly-valued NDATA, the same test shape
internal/edgepublish’s own suite uses, run here through the whole binary’s
actual wiring rather than against the publisher in isolation.
Live reload is tested the same way, against Modbus TCP slaves on real
sockets, the lab’s opc-plc, a real Mosquitto, and the Gateway’s own
Sparkplug host driver reading what the relay publishes
(internal/edgerelay/reload_test.go). One change adds an OPC UA device,
removes a Modbus one, changes another and leaves a fourth alone, and the
test checks: no NDEATH and the same bdSeq on every birth after it; the
removed metric nulled before the rebirth that drops it, and the host showing
it bad, not stuck on its last value; the new and changed tags reaching the
host with good values; the untouched device keeping its TCP session to its
slave; and no NDATA ever naming an alias its birth didn’t announce. A second
test applies a change while the broker is cut off and readings are piling up,
and checks the whole queue drains afterwards with the removed device’s
readings dropped, not sent. The compiled binary is tested too, as a
subprocess: a push applies in the same process, and a bad push leaves the
running list, the file on disk and /status saying why.
Discovery is tested the same way (internal/edgerelay/discovery_test.go,
internal/discovery/scout_test.go). A sensor announced on a real broker is
found by a running relay and its value read by the Gateway’s Sparkplug host
driver from the relay’s own output; a device withdrawn is retired from the
node’s birth in a rebirth, with no NDEATH; a pushed file adds and removes a
broker live; and a relay with writes allowed is asked to switch a discovered
pump, and refuses, with nothing on the pump’s command topic. The part that turns
announcements into devices is held to the Gateway’s: every recording of every
publisher in internal/discovery/testdata (Zigbee2MQTT, ESPHome, Tasmota,
Shelly) is fed through both the Gateway’s discovery on a real store and a
broker, and the Scout the relay uses, and they must end up with the same
devices carrying the same tags. They do. What that rests on is those recordings
and the maker’s documentation, the same as on the Gateway, and no device on a
bench.
Not yet run against real EtherNet/IP or S7comm hardware in this
configuration: those drivers are the same, already-tested code the Gateway
uses (see each protocol’s own section in docs/PROTOCOLS.md). OPC UA and
Modbus have been through the relay end to end in the live reload tests
above. Not yet run against a certified Sparkplug host application, the
same gap docs/EDGE-PUBLISH.md names for the Gateway’s own edge-publish
feature, this binary publishes through the identical code path, so it
inherits that gap rather than adding a new one.
Deploying it
Section titled “Deploying it”Two supported paths, both containers, no bare podman run
invoked from a shell that dies with the session, and no Docker:
charts/hotloop-edge-relay, a k3s Helm chart, separate from the Gateway’s own chart (charts/hotloop), since an edge relay is a genuinely different artifact that often runs in a different, smaller cluster than the Gateway it forwards to. Devices and tags are declared invalues.yamland rendered into a ConfigMap; the forward queue lives on a small PVC (see “Persistence off” below before you turn that off).deploy/quadlet, a Podman Quadlet unit for a Pi or a small industrial PC that is not part of a k3s cluster at all. Quadlet turns the container into a real systemd service: starts on boot, restarts on failure, logs to journald, see that directory’s own README for install steps.
Both use the same image (Containerfile.edge-relay, built and proven in CI) and
the same environment variables documented above.
Every release publishes both, from 4.16.0 on. The image is
ghcr.io/hotloop-io/hotloop-edge-relay, tagged with the release version (plus
major.minor, major, latest and stable; the Quadlet unit follows
stable, see Updating a relay). It’s amd64 and arm64, and 32-bit arm
(arm/v7) from the first release after 4.17.0, because a Pi 3 or a 32-bit Pi OS
install is exactly the box a relay ends up on. The chart is in the same Helm
repository as the Gateway’s:
helm repo add hotloop https://hotloop.io/hotloop/helm install edge-relay hotloop/hotloop-edge-relay \ --namespace hotloop --create-namespace \ --set edgePublish.broker=tcp://gateway.example.local:1883 \ --set edgePublish.group=Plant1Persistence off
Section titled “Persistence off”persistence.enabled=false gets you a relay that runs with no PVC, and
exactly that. On 4.16.0 it didn’t even run: nothing was mounted at /data,
the root is read-only, and the relay died at boot with open forward queue at /data/forward.db: no such file or directory. Now /data is an emptyDir,
capped at persistence.size, and everything the relay writes lands there.
An emptyDir lives as long as the pod. A container restart inside the same pod
keeps it. A new pod doesn’t, and you get a new pod on a node drain, a node
dying, an eviction, a kubectl delete pod, and any helm upgrade that rolls
the Deployment. Each time, you lose:
- The forward queue. Every reading buffered while the broker was down is gone, and nothing on the Gateway side knows it’s missing.
- A pushed device file. The relay boots from the chart’s
devicesagain without complaint, until the Gateway pushes again or the broker replays a retained push. That’s the exact failure the PVC is there to prevent, and turning it off opts you back into it. - The OPC UA client certificate and trust store. Every secured server has to trust the new certificate, and the operator has to approve every server’s certificate again.
Filling the cap, which a queue set unbounded (queueMaxBytes: 0; the default
is 64 MiB) will do in a
long enough outage, gets the pod evicted and loses all three at once. That’s
still better than filling the node’s disk under everything else on it. The
install notes print a warning when persistence is off. It’s fine for a trial.
For a relay whose readings matter, give it the PVC.
From the EmberNET App Store
Section titled “From the EmberNET App Store”The store lists the relay as HotLoop Edge Relay, one per node. A store deploy hands the chart three things: the display name (the release name), a node selector, and the tenant labels. It has no form to ask for anything else, and the relay can’t run without a broker and a Sparkplug group. So a store deploy of the relay stops at the render, on purpose, with an error in the helm-install job’s log that names what’s missing and prints the exact command to fix it.
We didn’t give it a default broker, because there isn’t one that’s true for anybody’s plant, and a relay pointed at a made-up address would look deployed while it forwards nothing. It can’t sit and wait for a pushed device file either: a push arrives over the broker connection it doesn’t have yet.
The fix is to put the two values into what the store wrote, and the install retries by itself:
# a node on the local cluster: add them under spec.valuesContentkubectl -n default edit helmchart hotloop-edge-relay-<node>
# a tenant on its own cluster (Fleet): add them under spec.helm.valueskubectl -n fleet-default edit helmop hotloop-edge-relay-<node>edgePublish: broker: tcp://<gateway-or-broker>:1883 group: <sparkplug group id>Once it’s up it shows in Running Apps under the name the store gave it, with
the HotLoop icon, scoped to the tenant that deployed it, and no LAUNCH button.
That last part is deliberate: the chart labels it embernet.ai/gui-type: none,
because /healthz and /status are for a probe and an operator’s curl, not a
page in an iframe.
The relay and the Gateway share a version number. A release won’t start unless
both charts carry the tag’s version and the relay’s Quadlet unit follows
:stable, so the relay gets a new version every release whether its own code
changed or not. That’s on purpose: one number to ask about, and no relay chart
pointing at a relay image nobody pushed. The Quadlet unit used to pin the
release number, which is the opposite of how a box that updates itself works.