INDUSTRIAL PROTOCOLS
Industrial Protocols
The v0.2.1 increment adds two new industrial transports — Modbus RTU and J1939 — while keeping protocol semantics in Nodra and hardware discovery/capture in Zyvor Device Agent.
Get the highlights.
-
2New industrial transports added in v0.2.1 — Modbus RTU and J1939
-
0x03Modbus function code the RTU transport implements for polling reads
-
29-bitExtended CAN frame format decoded by the J1939 connector
-
CRC16Checked on every Modbus RTU response for transport integrity
This increment keeps protocol semantics in Nodra while Zyvor Device Agent owns only physical hardware discovery/capture.
Take a closer look¶
The existing modbus connector now accepts transport: "tcp" (default) or transport: "rtu".
{
"type": "modbus",
"name": "boiler-plc",
"config": {
"transport": "rtu",
"device": "/dev/ttyS1",
"baud": 9600,
"data_bits": 8,
"parity": "none",
"stop_bits": 1,
"unit_id": 1,
"start": 0,
"quantity": 8,
"topic": "factory/boiler/plc/registers",
"interval": "2s",
"timeout": "1s"
}
}
The RTU transport implements function 0x03 reads and shares the existing connector poll/publish path. CRC16 is checked on every response. Serial configuration uses Linux termios and opens the device per transaction for deterministic unplug/replug recovery.
The kernel/board is expected to own RS485 direction control. Nodra does not silently rewrite RS485 mode or scan unit IDs.
Enable read-only CAN capture in Device Agent, then add:
{
"type": "j1939-device-agent",
"name": "truck-can",
"config": {
"url": "http://127.0.0.1:9188/api/v1/can/frames/stream",
"topic_prefix": "vehicle/j1939",
"reconnect": "2s"
}
}
The connector accepts only extended 29-bit data frames, derives priority/PGN/source/destination, and emits Nodra events such as:
vehicle/j1939/pgn/00F004
vehicle/j1939/pgn/00FF50
Standard CAN, RTR and CAN error frames are ignored. Nodra's normal ingest/WAL/local-route/cloud path remains unchanged.
Raw + decoded duplication: if j1939-device-agent consumes the Device Agent SSE stream, set Device Agent
industrial.can_capture.publish_to_nodra = false when only decoded PGN events are desired.
Keep it enabled only when both raw CAN archival topics and decoded J1939 topics are intentional.
A dependency-free, hand-rolled UA-TCP (binary) client, following the same dependency-free philosophy as the Modbus TCP building block. Scope is deliberately narrow:
- SecurityPolicy
Noneby default — anonymous session, no channel encryption or signing.Basic256Sha256scaffolding is wired: setsecurity_policytoBasic256Sha256,security_modetoSignorSignAndEncrypt(default), andclient_cert_path/client_key_path/server_cert_path. Without those cert paths the connector fails fast with an actionable error. Full secure-channel crypto (RSA-OAEP, PKCS#1/PSS signing, HMAC-SHA256 key derivation) is not shipped in this build yet — even with readable certs you get a clear "channel crypto is not available" error rather than a subtly wrong encrypted channel. Tracked inROADMAP.md. - Anonymous session only — no username/password or certificate-based user tokens (independent of channel security policy).
- Read and Subscribe/MonitoredItems —
mode: "poll"(default) ticks Read oninterval;mode: "subscribe"instead opens one long-lived Subscribe session and reconnects (waitingintervalbetween attempts) on any error. This is the least-verified part of the package: unlike Read/Write/Browse's single-request-response shape, Subscribe's multi-step CreateSubscription/CreateMonitoredItems/Publish protocol has more surface for a subtle wire-format mistake to hide — smoke-test against a real server (e.g. open62541) before production use. - Values must decode as a scalar Boolean/Int16/UInt16/Int32/UInt32/ Int64/UInt64/Float/Double/String/DateTime; an array or unsupported Variant type fails that poll cycle (Read) or is rejected before sending (Write).
{
"type": "opcua",
"name": "boiler-plc",
"config": {
"endpoint": "opc.tcp://boiler-plc.local:4840",
"node_ids": ["ns=2;i=1001", "ns=2;s=Temperature"],
"topic": "factory/boiler/opcua",
"interval": "5s",
"timeout": "5s"
}
}
Optional Basic256Sha256 config (fails until channel crypto lands; cert paths are required when this policy is selected):
{
"type": "opcua",
"name": "boiler-plc-secure",
"config": {
"endpoint": "opc.tcp://boiler-plc.local:4840",
"node_ids": ["ns=2;i=1001"],
"topic": "factory/boiler/opcua",
"security_policy": "Basic256Sha256",
"security_mode": "SignAndEncrypt",
"client_cert_path": "/etc/nodra/opcua/client.crt",
"client_key_path": "/etc/nodra/opcua/client.key",
"server_cert_path": "/etc/nodra/opcua/server.crt"
}
}
Each poll opens a fresh connection (Hello/Acknowledge, OpenSecureChannel, CreateSession, ActivateSession, Read, Close) rather than holding a session open across polls — the same per-call-dial pattern the Modbus TCP client uses.
Set "mode": "subscribe" instead of polling Read on an interval:
{
"type": "opcua",
"name": "boiler-plc-push",
"config": {
"endpoint": "opc.tcp://boiler-plc.local:4840",
"node_ids": ["ns=2;i=1001", "ns=2;s=Temperature"],
"topic": "factory/boiler/opcua",
"mode": "subscribe",
"interval": "1s",
"timeout": "5s"
}
}
This holds one connection open (CreateSubscription + CreateMonitoredItems
for every configured node, then a continuous Publish loop) instead of
dialing per tick — interval becomes the requested publishing interval
and, separately, the reconnect backoff after an error. Each server-
pushed value is emitted as its own event, one node per event, unlike
mode: "poll"'s single event batching every configured node.
Only the Value attribute's data changes are monitored (no Events, filters, or non-Value attributes); QueueSize is 1 with DiscardOldest, so a burst of rapid changes only ever surfaces the latest value, not a backlog.
Write, discovery and Browse are Go Client/package-level API calls,
not poller configuration — there's no nodrad.json config surface for
them, mirroring how Modbus's own WriteSingleRegister isn't wired into
its poller either:
cli := &opcua.Client{Endpoint: "opc.tcp://boiler-plc.local:4840", Timeout: 5 * time.Second}
// Write a single node's Value attribute.
status, err := cli.Write(ctx, opcua.NodeID{Namespace: 2, Numeric: 1001}, int32(72))
// Browse an address-space node's references.
refs, err := cli.Browse(ctx, opcua.NodeID{Namespace: 0, Numeric: 85}, 0, nil)
// Discovery services run over an unsecured pre-session channel, so
// they're package-level functions, not methods on an authenticated Client.
endpoints, err := opcua.GetEndpoints(ctx, "opc.tcp://boiler-plc.local:4840", 5*time.Second)
servers, err := opcua.FindServers(ctx, "opc.tcp://boiler-plc.local:4840", 5*time.Second)
Basic256Sha256 config scaffolding is in place (security_policy,
security_mode, cert paths on the poller and Client.Security); full
asymmetric secure-channel crypto remains a follow-up in ROADMAP.md —
shipping a subtly-wrong "encrypted" channel would be worse than failing
closed with a clear error when certs or crypto support are missing.
A protocol-agnostic passthrough connector for devices that don't speak
Modbus/OPC-UA/J1939 — it opens a serial device and publishes each
delimited (or idle-gap-framed) chunk of bytes as a Nodra event without
decoding any application protocol. It shares Linux termios
configuration with the Modbus RTU transport via internal/serialport
rather than duplicating it, and is Linux-only for the same reason (a
non-Linux build still compiles and registers the serial connector
type; it only fails, with a clear error, when actually started).
{
"type": "serial",
"name": "sensor-line",
"config": {
"device": "/dev/ttyUSB0",
"baud": 9600,
"data_bits": 8,
"parity": "none",
"stop_bits": 1,
"framing": "delimiter",
"delimiter": "\n",
"max_frame": 65536,
"topic": "factory/sensor/raw",
"timeout": "2s"
}
}
framing is "delimiter" (default, split on the one-byte delimiter,
itself defaulting to "\n") or "idle" (flush whatever's been read
after idle_timeout of silence on the line, default 100ms). timeout
is the reconnect backoff after an I/O error (e.g. device unplugged),
default 2s — it is not a per-transaction deadline like Modbus's
timeout, since this connector is a continuous read loop, not a
request/response client. If no frame boundary shows up within
max_frame bytes, the buffer is dropped (logged, reflected in
connector health) rather than grown unboundedly — almost always a sign
of a misconfigured delimiter or baud rate, not a legitimately huge
frame.
The kernel/board is expected to own RS485 direction control, same as Modbus RTU — Nodra does not silently rewrite RS485 mode.