Skip to content
GitHub

Protocols and drivers

This document says exactly what the gateway speaks, exactly how far each driver has been verified, and what to do about everything else. It is the first thing to read and the one to trust when something disagrees with it.

The list of native protocols is derived from the drivers compiled into the binary, /api/protocols, the Protocols screen and the MCP describe_protocol tool all read the same registry. The catalog cannot advertise a protocol the binary does not have.

Honesty about this is the whole point of the document, so it comes before the detail.

Protocol Native Verified against Status
Modbus TCP/RTU yes a real Modbus server, in-process, over TCP verified live
OPC UA yes Microsoft opc-plc (.NET) and Emberburn (Python), anonymous and unsecured; HotLoop’s own OPC UA server and opc-plc, for user name and password, Sign and SignAndEncrypt; Ignition 8.3.9, Basic256Sha256 SignAndEncrypt with a user name verified live against Ignition 8.3.9 and two other independent servers; NOT verified against KEPServerEX, WinCC, Siemens or Beckhoff
MQTT / Sparkplug B yes Eclipse Mosquitto 2.0 with crafted Tahu frames verified live
MTConnect yes golden agent documents, served over HTTP verified live
HTTP / REST yes real HTTP endpoints verified live
S7comm yes frame-level decode and encode tests NOT verified against hardware
EtherNet/IP yes address, batching and type-mapping tests NOT verified against hardware
UniFi Network yes a real UniFi Network Application (10.6) with emulated switches and a gateway adopted over the real inform protocol, logged in as a View Only account; a real UniFi OS Server (5.1, Network 10.6) for the UniFi OS login ; read-only against the house Cloud Gateway Ultra and Express 7 (Network 10.6.106) with real USW-Lite-8-PoE switches and a U5G Backup verified live, read-only, on real consoles and switches, WAN and failover included. PoE draw above zero not seen yet, see UNIFI.md

S7comm and EtherNet/IP have not been run against a physical PLC. Their decoding is tested thoroughly, every data type, sign extension, the two-byte S7 STRING header, byte ordering, block planning, read-modify-write for bits, but tested decoding is not the same as a value that came off a real controller. If you are the first person to point either at real hardware, treat it as commissioning and check a known value before trusting a screen.

Everything else in this table has moved real values off a real server.

OPC UA with a user name and password, over a secured channel, is verified against Ignition 8.3.9, the current stable release, in both directions: the driver reading Ignition’s server, and Ignition’s client reading the Gateway’s own OPC UA server. Setup notes, and the bugs Ignition flushed out, are under Ignition 8.3. It is also tested against HotLoop’s own OPC UA server (built on a different library from the driver, awcullen/opcua against gopcua) and Microsoft’s opc-plc. None of that is KEPServerEX, WinCC, a Siemens controller, or a Beckhoff one, and each of those has its own defaults and its own habits about certificates and token policies. Treat the first connection to any of them as commissioning. What was tested, and the one policy that failed against a server, is under OPC UA: security and credentials.

Prefer this one when a device offers a choice. It browses, it is properly secured, and it subscribes.

The driver subscribes rather than polls. The server sends a value when it changes, at a rate it can sustain, instead of being asked the same question every scan by a client with no idea whether anything moved. If a server refuses a subscription the driver falls back to batched reads rather than failing the device.

It also falls back when a subscription delivers nothing at all. A server sends every node’s current value in its first publish and never again until it changes, so a lost first publish means a setpoint that reads bad for as long as the session lasts. If ten publishing intervals pass (five seconds at least) with no value from the subscription, the driver logs a warning, deletes the subscription and reads the tags on every poll until the device next reconnects. The OPC UA client library this driver is built on can start its publish loop paused on a loaded host and never send a publish request; that is the case this exists for.

Address the nodes by namespace URI. This matters more than it looks:

nsu=http://opcua.edge.server;s=Zone1_Temperature good
ns=3;s=Zone1_Temperature works until it doesn't

A namespace index is assigned in whatever order the server happens to load its namespaces. Add a namespace, upgrade firmware, and index 3 quietly becomes something else, every reading is then wrong with no error anywhere. The URI is part of the model and does not move. Browse returns the URI form for exactly this reason.

A bare string is rejected as an address. gopcua would happily read Temperature as ns=0;s=Temperature, which means a typo parses cleanly into a node that does not exist and the tag reads bad forever. An explicit form is required so the mistake surfaces when you save it.

Discover tags declares each tag as the type the server says the node holds: Boolean is bool, the integer types keep their width (int16, uint16, int32, uint32, int64, uint64; SByte and Byte take the next size up), Float and Double are float, and String, DateTime, LocalizedText and the other types the driver reads as text are string. A type the server will not say, or one with no better name (an enumeration, a structure), stays float. Until the Ignition work below, every discovered tag was float. The driver still read a String node’s text, but the tag said float, and the Gateway’s own OPC UA server publishes a tag as the type it is declared, so a client of that server got Bad_TypeMismatch for it. Ignition showed Emberburn’s System_Status exactly that way. Tags imported before the fix keep their type: change a text tag to string by hand.

Configuration: the security, identity and certificate options are described under OPC UA: security and credentials. The rest are subscribe, publishIntervalMs, samplingIntervalMs, queueSize, sessionTimeoutMs, browseMaxNodes, browseRoot, and browseServerNodes. An option name the driver does not know is an error that lists the ones it does, not a setting that is silently ignored: a typo in securityMode would otherwise connect with no security at all and look exactly like a working device.

Discover tags leaves out the server’s own Server object (i=2253: its status, capabilities, diagnostics, namespace array and configuration). Every OPC UA server has one, and on opc-plc it was hundreds of variables mixed in with the plant’s own. Set browseServerNodes to true on the device to get them back, or set browseRoot to i=2253 to look inside it on purpose; a browse that starts there still walks it.

Until 4.15.0 the driver could only make an anonymous, unsecured connection, and not to any server that follows the specification: it never asked the server which identity token policies it offered, so it sent an empty token policy id, which a compliant server refuses. That included HotLoop’s own OPC UA server. The driver now discovers the server’s endpoints, picks one that matches the device, and echoes the policy id that endpoint listed.

Option Default What it does
securityMode None None, Sign, or SignAndEncrypt. Left out with a securityPolicy other than None, it is SignAndEncrypt.
securityPolicy the best the server offers for the mode None, Basic256Sha256, Aes128_Sha256_RsaOaep, or Aes256_Sha256_RsaPss. Basic128Rsa15 and Basic256 are refused.
username, password anonymous User name authentication.
allowInsecureCredentials false Allows a password to be sent over an unsecured channel (securityMode None), or when nothing encrypts it.
certFile, keyFile generated This client’s application certificate and its RSA key, for a secured mode. Set both or neither.
pkiDir HOTLOOP_OPCUA_CLIENT_PKI_DIR (/data/opcua-client-pki) Where the generated certificate and the trusted/ and rejected/ stores live.
serverCertThumbprints none SHA-1 (40 hex digits) or SHA-256 (64) thumbprints of server certificates to trust without a file in trusted/. Colons and spaces are ignored.
insecureSkipVerify false Accept any server certificate.

A device with none of these set is what it always was: anonymous, over an unsecured channel. That is unchanged, and the tests against opc-plc that predate this work still pass unmodified.

The driver asks the server for its endpoints over an unsecured channel, which the specification requires servers to allow for discovery and which carries nothing secret, since the endpoint list is public by design. It keeps the endpoints whose security mode and policy match the device, and among those the token policy for the device’s identity (anonymous, or user name when a username is set). The identity token it then sends carries the policy id that endpoint listed; it never sends an empty one.

  • It never selects Basic128Rsa15 or Basic256. Both are deprecated by the OPC Foundation. This holds when one has the highest security level on the list, which is how servers rank them, and when one is the only thing the server offers, in which case the connection fails with an error that says so. A device cannot be configured to use one, and a token policy that would encrypt a password with one is refused as well. HotLoop’s own server offers both alongside the modern policies and cannot turn them off, so this is exercised against a real server, and the tests check that neither policy’s URI appears in what the driver sends.
  • When the device names no policy it prefers Basic256Sha256, then Aes128_Sha256_RsaOaep, then Aes256_Sha256_RsaPss, whatever security level the server gives them. The order is by how widely each works, not by strength. Against opc-plc, Aes256_Sha256_RsaPss fails: the library the driver is built on cannot open a channel with it, and the connection is closed with nothing to say why. A bare gopcua client with none of this driver’s code fails the same way, and the policy works against HotLoop’s own server. It is used only when a device names it.
  • If nothing matches, the error lists what the server does offer, with the policy and token policy id of each endpoint, so the configuration can be fixed from the message alone.

Any secured mode needs an application certificate. If the device names a certFile and keyFile they are used as they stand, after checks (RSA, at least 2048 bits, not expired). Otherwise a self-signed one is generated on first use and kept in the PKI directory: RSA-2048, five years, the application URI urn:hotloop:client:<host name> in the subject alternative names, the key at mode 0600, the certificate at 0644, and the directories at 0750. The layout mirrors the OPC UA server’s:

own/cert.pem, own/key.pem this client's certificate
trusted/ server certificates an operator has approved
rejected/ server certificates the driver turned away

It is not regenerated on a routine start, so a server that has been told to trust it is not told again. It is replaced, with a warning that every server must approve the new one, only if it cannot be read, is not RSA of at least 2048 bits, or has under 30 days left. Devices that start at the same moment share one certificate rather than each writing their own.

The directory must be writable and persistent. In the Helm chart that is opcuaClient.persistence.enabled (off by default, because it adds a volume to an existing install); on the edge relay it is the existing /data volume. Without a writable directory a secured device reports an error that names the variable to set, rather than connecting with certificates that would be lost at the next restart.

The server must also trust this certificate, which is the server’s own setting. For HotLoop’s own server that is the same move: the driver’s certificate appears in the server’s rejected/ directory after the first refused attempt, and moving it to trusted/ approves it. When a server closes the connection while the secure channel is opening, which is how many servers turn away a certificate they do not trust, the error names this client’s certificate file and thumbprint so the operator knows what to look for on the server.

For a secured mode, or for a password that is to be encrypted to the server’s key, the driver does not accept whatever certificate the server presents. It trusts the certificate only if it is pinned in the device’s serverCertThumbprints or is the exact certificate in trusted/. Anything else is written to rejected/<thumbprint>.der and the connection fails, before any credential is sent, with a message like:

opcua: the server at opc.tcp://plc-01:4840 presented a certificate that is not
trusted (subject "CN=PLC01", SHA-1 thumbprint 5A6DB35A..., expires 2031-09-18).
It has been saved to /data/opcua-client-pki/rejected/5A6DB35A....der. To approve
it, first check that the thumbprint matches the one the server shows, then move
that file into /data/opcua-client-pki/trusted (the next connection attempt picks
it up; no restart is needed). Alternatively pin it by adding the thumbprint to
this device's serverCertThumbprints. Until then this device will not connect

Check the thumbprint against the server’s own display before approving: that comparison is the whole of the trust decision. Then mv the file into trusted/ (PEM and DER files are both read), and the device connects on its next attempt, which is within the runtime’s reconnect interval. Some details:

  • Trust is by the exact certificate, not by a chain. A server whose certificate is issued by a CA is approved by placing that server’s own certificate in trusted/, the same as a self-signed one. Trusting a CA is not supported.
  • A certificate that has expired but was approved by an operator is still trusted, with a warning in the log on each connect. Refusing one an operator approved on purpose would only push them toward insecureSkipVerify.
  • A file in trusted/ that is not a certificate is skipped and reported, and does not make the other approved servers unreachable.
  • rejected/ holds at most 256 files, so a server that presents a different certificate on every connection cannot fill the disk. Past that the connection is still refused, and the message says the file was not kept and how to approve the certificate anyway.
  • insecureSkipVerify turns the check off. A party in the path can then impersonate the server, and read what is sent to it, including the password. It is off by default and warns, with the certificate’s thumbprint, on every connect.

An anonymous device on an unsecured channel needs none of this, because nothing is signed or encrypted to the server’s key.

A password is refused, before it is sent, unless the channel is secured and something encrypts the password. What the driver decides, from what the server offers for the endpoint it matched:

Channel The server’s user name token policy Result
SignAndEncrypt encrypts the password (the usual case) connects
SignAndEncrypt does not connects: the encrypted channel carries it, and the log says so
Sign encrypts the password connects
Sign does not refused unless allowInsecureCredentials. Sign authenticates messages but does not hide them.
None encrypts the password refused unless allowInsecureCredentials. On an unsecured channel the key the password is encrypted to comes from a reply nothing authenticates, so anyone who can alter traffic can recover it. With the opt-in it connects, with a warning.
None does not refused unless allowInsecureCredentials

A user name over securityMode None is refused even when the server’s token policy encrypts the password. Encryption whose key an attacker can choose is not protection against that attacker, and on an unsecured channel the certificate the password is encrypted to is taken from the server’s reply to CreateSession, which nothing authenticates (the test below substitutes a certificate and recovers the password). The error says this and names the fix, securityMode SignAndEncrypt. Anonymous sessions are not affected.

allowInsecureCredentials is the one switch that lets a password go out on those terms. It is off by default and warns on every connect when it is what allowed the connection. Where the token policy encrypts the password it changes what a listener can read, not what a party that alters traffic can: see what crosses the network. Even with it, the server’s certificate must be approved first when the password is encrypted to it.

The password is a secret everywhere the driver and the API handle it:

  • API responses. It is replaced by [redacted] in every response that returns a device (GET /api/devices, GET /api/devices/{id}, and the response to a save), so a viewer, who holds devices.read, cannot read it. As of 4.15.1 this holds for every role, admin included, and for the credentials of every protocol, not only this one. Saving a device back with the placeholder keeps the stored password; a new value replaces it; an empty string clears it; the placeholder for a device with no password is refused rather than stored. Leaving the field out now keeps it (in 4.15.0 it cleared it). The full rules are in AUTH.md. The web UI’s device editor shows the configuration as the API returns it and saves what it shows, which this handles, so editing a device does not lose its password. (The tests exercise that through the API, not through a browser.)
  • Logs and errors. Neither the driver’s log lines nor any error it returns carries the password: the messages are built from status codes and the device’s non-secret settings. The tests capture the process log, the server’s log, and the errors, and search all of them for the password.
  • MCP tools and resources, the live event stream, the system log. None returns a device’s configuration (each describes a device through a fixed set of fields), and tests call every tool and resource, open the stream, and read the system log with a password configured, and find it nowhere.
  • The database and backups. The password is stored in the device’s config column as the other drivers’ are. Up to 4.16.0 that’s in the clear, and a backup contains it. From 4.17.0 it’s encrypted with a key kept outside the database (see “Encrypted at rest” below), so a dump or a backup holds ciphertext. Either way, see BACKUP.md and protect the database and the backup volume accordingly.
  • The edge relay. A device file carries the password too. In the Helm chart, put a device file with credentials in a Secret and name it in devicesSecret; devices set directly in the chart’s values end up in a ConfigMap. A device file pushed through fleet config push is published, retained, to an MQTT topic, so the broker must carry TLS and restrict who may read that topic.

A wrong password is a clear error, in one attempt, with no hang and no retry inside the driver:

opcua: authentication rejected: the server at opc.tcp://plc-01:4840 rejected the
user name and password for "svc-hotloop" (StatusBadUserAccessDenied). ...

The runtime then waits ten minutes (jittered to between five and ten) before the next attempt, instead of climbing through its usual reconnect backoff (HOTLOOP_RECONNECT_MAX, 60 seconds by default). Two things make a refused login more expensive to repeat than a network fault. A server that locks an account after a few failures turns every quick retry into an outage for whoever else uses that account. And each refused login can leave a session that was created and never activated on the server, which counts against its session limit until it times out (the library asks for twenty minutes; sessionTimeoutMs changes it): against HotLoop’s own server, a device with a wrong password retrying every few seconds filled its default limit of twenty sessions, after which the server refused every other client with BadTooManySessions. A controller with a small session table is more exposed than that. The device does not stop retrying, so a server whose password is fixed on its side recovers within ten minutes without anyone touching HotLoop; correcting the password on the device, or switching it off and on, starts a new worker, which tries at once.

Measured, not reasoned: the tests put a recording proxy between the driver and a server and search every byte in both directions. The search finds bytes known to be there (the server’s endpoint list, sent in the clear), which is what shows the proxy recorded the conversation.

Configuration Password User name Tag node ids and values
SignAndEncrypt not visible not visible not visible
Sign not visible (the token policy encrypts it) readable readable
None, user name, server’s token policy encrypts (needs allowInsecureCredentials) not visible readable readable
None, anonymous not applicable not applicable readable
a server that offers an unencrypted password, with allowInsecureCredentials readable readable depends on the mode

The last row is tested against a server that does not really offer that: a party in front of a real server rewrites its endpoint list to say the password is unencrypted, on the channel discovery uses, which nothing authenticates. That is also how the tests show the refusal: without the opt-in the driver refuses, opens only the discovery connection, and no credential is on the wire. The endpoint list is exactly the thing a party in the path can rewrite, which is why the driver’s rule is applied to what it is told and not left to the server to enforce.

An unsecured channel with a password has a limit the table does not show, and a test does; it is why the driver refuses that combination without the opt-in. The password is encrypted to the server’s certificate, which keeps it from a listener, and the driver will not send it until that certificate is approved. But the certificate the OPC UA library encrypts to is the one in the server’s reply when the session is created, and on an unsecured channel that reply is not authenticated. A party that can alter traffic puts its own certificate in it, and the password is then encrypted to that party’s key: the test does this, decrypts the password with the substituted key, and gets it back. Over Sign or SignAndEncrypt the same party cannot, because the reply is protected by keys it does not have. Without allowInsecureCredentials the driver refuses to send the password at all, and the test shows the same party then gets nothing; with it, the driver warns on every connect. Use SignAndEncrypt wherever the server offers it; an unsecured channel with a password, opted in, is protection against a listener and nothing more.

The OPC UA library’s own reconnect is switched off, and the runtime reconnects. The library, once a session is lost, opens a new one with the credentials it was given and, when the server refuses them, tries again straight away with no delay: about twenty refused logins in twelve seconds was measured, against a server whose password had been changed. That is how an account gets locked out for everyone who shares it. Now a lost session makes Poll report an error, the runtime’s worker closes the driver and opens a new one, and the new one discovers the endpoints, checks the server’s certificate, and signs in again, with the runtime’s jittered backoff and the ten-minute wait after an authentication failure. A restart of the server is survived the same way: the device reads the new value after the restart, having signed in again, which the tests check by counting the server’s logins and by changing the value after the restart.

A device with no tags has nothing to read, and until 4.15.2 that meant its poll sent nothing at all. The server closed the idle session after its session timeout, the device kept saying connected because nobody had asked the server anything, and every browse failed with BadSessionIDInvalid until the process restarted. That is every device at the exact moment somebody adds it and clicks Discover tags. Ignition caught it. Now a device with no tags reads the server’s state on every poll, which keeps the session alive and notices when it dies, and a browse that finds the session gone reports it so the runtime reconnects.

A tag that has not changed since before the session began now reads its current value with good quality. Before, the placeholder reading reported while waiting for the first value was newer than the value’s own source timestamp, and the runtime keeps the newest reading and discarded the value, so a stable tag read bad until it next changed.

Tested, against HotLoop’s own OPC UA server, with a real database, real users, and real bcrypt hashes, and against Microsoft’s opc-plc:

  • anonymous and user name and password over None, Sign and SignAndEncrypt, with Basic256Sha256, Aes128_Sha256_RsaOaep, and (against HotLoop’s server only) Aes256_Sha256_RsaPss;
  • the operator’s whole journey: a server certificate refused and saved, approved by moving it, then the server refusing the driver’s certificate and the message that says so, then both approved and a value read;
  • a wrong password, an unknown server certificate, a user name over None refused without allowInsecureCredentials and, with it, refused until the server’s certificate is approved and then connecting, a deprecated policy named and left to be chosen, and the credential rules above against a rewritten endpoint list;
  • a restart of the server, a password that stops working after the device has connected, and a stable tag, each through the runtime’s worker and a second HotLoop instance;
  • a device with no tags left idle past the server’s session timeout, then browsed, and the same device after the server restarts under it;
  • Ignition 8.3.9, below;
  • the anonymous tests that predate this work, against opc-plc, unchanged, and the Emberburn interop tests against a python-opcua (freeopcua) server standing in for Emberburn, which is built on that library. Emberburn itself was not available for this change, so the path through the new endpoint selection has not been run against it;
  • the password absent from API output, the live event stream, the system log, MCP output, the driver’s log, the server’s log, and every error string;
  • go test -race -count=10 on the driver and OPC UA server packages.

Not verified. KEPServerEX, WinCC, Siemens, Beckhoff and any other production server except Ignition, and Ignition only as far as the next section says. Each may behave differently about which policies it offers and how it turns away a certificate. Also not tested or not supported:

  • A change in behavior for existing devices, in the few cases it applies. Every connect now asks the server for its endpoints first, and a device with no security options needs an unsecured endpoint that lists an anonymous token policy with an id. A server that accepted anonymous sessions without listing one, which worked before only because it was not checking, is now refused with an error that lists what it offers.
  • X.509 certificate and issued-token user authentication. Before 4.15.0 a device with certFile tried to use it as a user certificate and could not work; it is now the application certificate, and user certificate authentication is not implemented.
  • A silently dead connection, one where the network path drops without either end seeing it close. The library treats a timed-out publish request as nothing to act on, so a device on such a link keeps its last values, and the runtime is not told the session is gone, until the operating system gives up on the connection. This was already true with the library’s own reconnect on; it is listed here because the runtime now owns reconnection and depends on the session state to know when to.
  • Trusting a certificate authority, and certificate revocation. Trust is by exact certificate.
  • Elliptic-curve security policies, and servers that refuse discovery on an unsecured channel.
  • Aes256_Sha256_RsaPss against a server other than HotLoop’s own, where it failed, as above.
  • Whether a server’s own password limits apply: HotLoop’s own server, for one, cannot log in a user whose password is longer than 64 bytes over OPC UA (the library refuses it, see OPCUA-SERVER.md).

Verified against Ignition 8.3.9, the current stable release, in Inductive Automation’s own container image: HotLoop’s driver as the client, Ignition’s OPC UA server on the other end, Basic256Sha256 with SignAndEncrypt, signed in with a user name and password. Browse found Ignition’s tags under nsu=urn:inductiveautomation:ignition:opcua:tags, and the [System]/Gateway clock, CPU, memory and uptime tags imported and read with good quality, pushed by subscription.

A stock 8.3 gateway will stop you four times before HotLoop gets a word in:

  1. Its OPC UA server listens on localhost only. Out of the box, 8.3 binds port 62541 to localhost, so a HotLoop anywhere else gets connection refused and nothing else. In the Gateway, under the OPC UA server settings, set Bind Addresses to 0.0.0.0. We also added the host name HotLoop dials to Endpoint Addresses; whether the driver needs that was not tested.
  2. Tags are not on OPC UA by default. Turn on Expose Tag Providers, or all a browse finds is the server’s own diagnostics and the device folders.
  3. Certificates, both ways. HotLoop refuses Ignition’s certificate until you approve it, as above. Ignition refuses HotLoop’s until you trust it in the Gateway’s OPC UA server certificates. On disk that is moving it from data/config/local/com.inductiveautomation.opcua/server/security/pki/rejected/certs to .../trusted/certs, no restart. Ignition does not name that file by the SHA-1 thumbprint HotLoop logs. Open the certificate and compare its fingerprint; the file name will not match and that is not a sign of an impostor.
  4. The OPC UA login is opcuauser / password in the opcua-module user source, on every fresh install. Change it before anything real connects.

Only what 8.3’s server offers out of the box was exercised in this direction: Basic256Sha256 and SignAndEncrypt.

The other direction is verified too: Ignition 8.3.9’s OPC UA client reads HotLoop’s OPC UA server, signed in as a HotLoop viewer, over every security policy and mode Ignition offers. The setup, the certificate trust in both directions, the gotchas and the policy matrix are in OPCUA-SERVER.md. It found two HotLoop bugs, both fixed: the server minted a new certificate every time its container was recreated, which broke Ignition’s trust after every upgrade, and Discover tags declared every tag a float, so a text tag reached Ignition as Bad_TypeMismatch (above). test/ignition/lab.py repeats the whole verification against a running HotLoop and prints PASS or FAIL.

HotLoop’s Sparkplug edge node and Edge Relay against Ignition’s MQTT Engine are next and not done.

The 1979 workhorse. PLCs, VFDs, power meters, RTUs, and the fallback of every gateway that has nothing better.

holding:100:float32:CDAB a float across registers 100-101, word-swapped
holding:40:bit3 bit 3 of register 40
holding:200:string16 16 characters from register 200
input:12:int16 a signed input register
coil:5 a single coil

Word order is the setting people get wrong, and a float read with the wrong order is not obviously wrong on a screen, it is a plausible number that is not the truth. All four permutations are supported by the names vendor manuals print: ABCD, CDAB, BADC, DCBA. Set a device default with byteOrder and override per tag with the fourth address field.

Addressing is raw and zero-based. If your drawings use the traditional 40001-style numbering, set oneBased: true and write the numbers from the drawings. Getting this wrong by one is the oldest Modbus bug there is.

Reads are merged into blocks: adjacent and near-adjacent registers become one request. maxGap controls how many unused registers are worth reading to avoid a second round trip (default 8), maxBlock caps a single request (default 120, protocol ceiling 125).

Writing a single bit of a holding register is a read-modify-write, because Modbus has no atomic bit-set there. The other fifteen bits are preserved.

Plain MQTT, or Sparkplug B as a full primary host application.

Plain topics:

mqtt:plant/line1/temperature a scalar payload
mqtt:plant/line1/state|data.temperature JSON, extracted by path
mqtt:plant/+/flow wildcards work

Sparkplug metrics:

spb:GroupId|EdgeNodeId|DeviceId|Metric/Name
spb:Plant|Edge1||Node/Uptime empty device = a node-level metric

As a primary host the driver does the things the specification asks for and most integrations skip:

  • Publishes a retained STATE birth with a matching will, so edge nodes can see the host drop rather than inferring it from a timeout. Sparkplug 3.0 form (spBv1.0/STATE/<host> carrying JSON) by default; set legacyState for the pre-3.0 ONLINE/OFFLINE string.
  • Tracks the per-node rolling sequence number and asks a node to rebirth when it skips, because a gap means messages were missed and the alias table can no longer be trusted.
  • Requests rebirth after its own reconnect. A host that was away has no idea what changed while it was gone.
  • Matches NDEATH to NBIRTH by bdSeq, so a death certificate left over from a previous session cannot kill the current one.

Message ordering is enforced. Delivering Sparkplug messages concurrently reorders them, which makes a healthy node look like it is dropping messages and provokes a rebirth storm.

Allen-Bradley ControlLogix, CompactLogix and Micro820. Tags have names, so the controller can be asked what it holds.

Temperature type from the tag's declared dataType
Speed:dint an explicit type wins
Program:MainProgram.Speed a program-scoped tag, the colon is part of the path
Zones[0].Heater.On:bool array elements and UDT members

CIP is strongly typed and the controller rejects a read whose declared type does not match, so the type has to come from somewhere. It is taken from the tag’s dataType, or from a :type suffix which wins. Unknown falls back to REAL, which most process values are.

Reads are batched into multi-service requests, falling back to individual reads when a batch fails so that one bad tag path does not blind the other nineteen.

Siemens S7-300, 400, 1200 and 1500, over ISO-on-TCP by rack and slot.

db1:0:real a REAL at DB1.DBD0
db1:4:dint a DINT at DB1.DBD4
db1:8.3:bool bit 3 of DB1.DBB8
db2:20:string32 a 32-character STRING
m:10:word merker word MW10
i:0.1:bool input I0.1
q:2:byte output byte QB2

The type is required for anything wider than a byte. The protocol cannot tell you whether four bytes are a REAL or a DINT, that is a fact about the PLC program, not about the wire.

Defaults to rack 0, slot 1 (S7-1200/1500). S7-300/400 are usually slot 2. connectType defaults to 3 (S7 Basic) so the gateway does not occupy the PG connection slot an engineer wants for TIA Portal.

There is no browse. An S7 CPU does not publish the layout of its data blocks, that lives in the TIA Portal project.

Read-only machine tool telemetry. There is no command channel in MTConnect, and this driver does not pretend otherwise.

Address by dataItemId or by name; agents are inconsistent about which is stable, and machine documentation shows one or the other.

Two details that separate a working integration from a plausible one: UNAVAILABLE is the agent explicitly saying it has no reading, it becomes bad quality, not the text “UNAVAILABLE” and not zero. And a Condition’s meaning is its element name (Normal, Warning, Fault, Unavailable), not its character data, which is usually empty.

Browse reads /probe and walks the component tree to any depth.

The catch-all, for energy meters, environmental sensors, OEM controllers and the REST side of a gateway that speaks something proprietary underneath.

power.kw nested fields
readings[0].value array indexing
zones[name=Zone1].temp select an array element by a field value
$.data.temperature a leading $. is accepted

That third form is the one that matters: these APIs love returning an unordered array of {name, value} objects, and indexing by position is a bug waiting for a firmware update.

One request serves every tag on the device. Browse walks the document and reports every scalar with the path that reaches it.

The plant network as a device: one site on one UniFi console, its switches, every switch port, PoE draw, the WAN links and the site’s health, as read-only tags that create themselves as gear is adopted. One login per console, the certificate pinned, and the console password encrypted in the database. It never writes to the console. It also listens to the console’s events websocket, so a switch dropping off is in the logbook when it happens, with polling still the truth underneath, and the UniFi tab draws the overview, the map, the faceplates, the clients, the networks and the WiFi. Everything about it is in UNIFI.md.

PROFINET · EtherCAT · HART · BACnet/IP · DNP3 · FINS · MELSEC/SLMP · CANopen · FANUC FOCAS

These are not spoken natively and there is no driver for them. That is a deliberate choice, not a gap waiting to be filled with a stub.

Sites running these protocols almost always already have a gateway that re-publishes on OPC UA or Modbus, a PROFINET IO controller with an OPC UA server, a HART multiplexer with a Modbus map, a BACnet/IP to Modbus bridge. Point a device at that endpoint and configure it as OPC UA or Modbus.

Writing nine more drivers that each duplicate what a site’s existing gateway already does would add nine more things to get subtly wrong, for protocols where the equipment is hardest to test against. If a site genuinely has no gateway and needs one of these natively, that is a real piece of work to schedule, not a file to add that logs “not implemented”.

There are no placeholder drivers in this tree. A protocol either has a working implementation or it is in this section. The previous version of this product had sixteen protocol files of which fifteen returned "not implemented", while the catalog advertised all sixteen. A test now enforces that every protocol the model declares has a driver compiled in, and vice versa.

A device’s config and address are where a driver finds what it logs in with. These are the credentials each protocol reads, and all of them are write-only through the API (4.15.1): they show as [redacted] to every role, a save that sends the placeholder or leaves the key out keeps the stored value, and a new string replaces it. The rules, and what does not go through the API, are in AUTH.md.

Protocol Credentials in the configuration Credentials in the address
OPC UA password (with username) none: user information in the address is hidden, and the driver ignores it
MQTT / Sparkplug B password (with username) mqtt://user:pass@host, which the client uses
HTTP / REST password (with username, sent as basic auth); every value in headers except plain ones such as Accept and Content-Type, which is where an API key or Authorization header goes; body and writeBody if they mention a credential http://user:pass@host and ?api_key= style query values, in the address and in writeUrl
MTConnect password (with username); every value in headers, with the same exceptions http://user:pass@host and query values
UniFi Network password (with username). Only ever stored encrypted: without a key (below) the device can’t be saved none: the address is the console’s https:// URL
Modbus, S7comm, EtherNet/IP none none: user information in the address is hidden, and the driver ignores it

Encrypted at rest. With HOTLOOP_SECRET_KEY_FILE set (the chart sets it, with a key it generates), every credential in the configuration column above is encrypted in the database with that key, which lives outside the database, so a dump or a backup holds ciphertext. The rest of HotLoop, and the drivers, never see the difference. A credential written into a URL (mqtt://user:pass@host) or into free text is hidden from every screen but stays in the clear in the database; use the password option. See BACKUP.md.

Any other option whose name looks like a credential (it contains password, token, secret, apikey, authorization, private, passphrase, or ends in key, or has a whole word such as pass, pw, auth, cred, or sig, among others) is hidden too, whatever protocol it is in, so an option added to a driver later is hidden until someone decides it should not be. A test walks every driver’s options and fails until each one is on the list of credentials or the list of options known not to be. See AUTH.md for the full rules.

  1. Devices → Add device. Pick the protocol, give it an address.
  2. Discover tags where the protocol supports it: OPC UA, EtherNet/IP, MTConnect, MQTT (after a Sparkplug birth), HTTP. Modbus and S7comm cannot enumerate their own points, so declare those from the device documentation.
  3. Imported tags are created read-only. A point the device happens to expose is not a point somebody has decided may be commanded. Mark tags writable one at a time, deliberately.
  4. Set scale and offset for raw counts: engineering = raw × scale + offset. If the instrument sends a code when a channel is dead or out of range, list it under No-data values so it reads bad instead of as a number.
  5. Set a deadband on anything noisy. It suppresses historian writes for changes too small to matter, and without it a 12-bit sensor jittering in its last bit writes a row every scan forever.

A recorder with a burnt-out thermocouple does not go quiet. It keeps answering, and what it puts in the register is a code: 0x8005 for “undefined”, 0x7FFF for “over range”. Read as an int16 with a scale of 0.1, 0x8005 is -3276.3 degrees, and until I fixed this it came back as good quality, went into the historian as a real temperature and sat there for the alarms to judge. A dead sensor looked like a cold one.

So a tag can list the raw values that mean “no measurement”. Each one has a reason. When a reading’s raw value is on the list, the reading is bad, the reason is on it, and scale and offset never touch it.

{
"deviceId": "dx-1", "name": "ch01", "address": "input:1:int16",
"dataType": "float", "unit": "C", "scale": 0.1, "enabled": true,
"noData": [
{"raw": "0x7FFF", "reason": "+over"},
{"raw": "0x8001", "reason": "-over"},
{"raw": "0x8002", "reason": "skip"},
{"raw": "0x8004", "reason": "error"},
{"raw": "0x8005", "reason": "undefined"},
{"raw": "0x7F7F", "reason": "power fail"},
{"raw": "0x7FFA", "reason": "burnout up"},
{"raw": "0x8006", "reason": "burnout down"}
]
}

Those eight are the Yokogawa DX family’s codes, from the DX communication manual (IM 04L41B01-17E, section 4.3, page 4-35). They are not special to HotLoop: it is the same field on every tag of every protocol, and the DX list is just the one that bit us first. Each one is on the tag, so a recorder channel that really can read 0x7F7F in some other unit keeps it as a reading.

Field Details
raw The device’s code, written the way its manual prints it: "0x8005" (hex), "-32763" or 32773 (decimal). A string or a bare number. Required
reason What the reading’s quality says. Up to 120 characters. Left out, it reads raw value 0x8005 means no measurement, so a reading never goes bad without saying why

In the tag editor it is the No-data values box: one code per line, the code, a space, the reason.

For a register-backed Modbus point the code is the integer the device holds, after the point’s byte and word order is applied. That is the same number the point decodes to, so a manual that says “the instrument returns 8005h” means exactly "0x8005" here.

  • int16 and the other signed types. 0x8005 and -32763 are the same register, and so is 32773. Write whichever the manual does; all three match. A code that does not fit the type (0x10000 on an int16) is refused when the device connects, because a list that can never match is worse than an error. Two spellings of one value on one tag are refused too.
  • uint16 and the other unsigned types. 0 up to the type’s maximum. A negative number is refused.
  • int32, uint32, int64, uint64. The same rules at 32 and 64 bits. 0x7FFFFFFF on an int32 with CDAB word order is the value after the words are put the right way round, not the order they came off the wire in.
  • float32 and float64. The code is the register bit pattern, so it has to be hex: 0x7FC00000 for a quiet NaN. A decimal is refused, because 32773 as a float and 32773 as a bit pattern are different things and I won’t guess.
  • Bits, coils, discrete inputs and strings. No raw number to compare, so noData on one is refused when the device connects.

The check is on the raw bits, before decode, before scale, before offset.

Every driver that hands over an already decoded number checks the same list: S7comm, EtherNet/IP, OPC UA, HTTP and MQTT (including discovered devices). What they compare is that decoded number, as a whole number, so write it as the number it is. An S7 INT holding 0x8005 is -32763, so write -32763. Hex here is only another way to write the same number, because a decoded number has lost its width and can’t be read back as a bit pattern. A fractional value never matches. The list is only worth setting where the device sends codes like these; most of what MTConnect and UniFi report never does.

  • Quality is bad and the reading carries the reason. It shows on GET /api/values and the live stream (reason), next to the quality on the Tags screen, and on the tag’s entities as the quality_reason attribute.
  • The value is empty, not zero and not the scaled code. Scale and offset are not applied.
  • The historian records the quality as bad, the same as any other bad reading, and its value column holds nothing meaningful. A bucket with no good reading shows as a gap on a trend, as it does for any bad reading.
  • Alarms. A bad reading never trips a value alarm: high, low, equal, rate of change all hold their previous state, same as for any bad reading. A bad-quality alarm on the tag does fire, and its default message carries the reason (dx-1/ch01 bad-quality: undefined). Use {reason} in your own message to put it there.
  • The logbook records the tag’s entity going bad, and the reason is the entity’s quality_reason attribute.

On the tag, saved with it. Through the API it is the noData list on POST /api/tags (see API.md), in the editor it is the box above, and in an Edge Relay it is the same JSON in the device file’s tag (see EDGE-RELAY.md). Saving the tag reloads the running device, so a change takes effect on the next poll and nothing restarts by hand. A re-discovery leaves a tag’s list alone.

Every reading carries good, uncertain or bad.

Bad means the value is not known. It does not mean zero. A driver that cannot read a point still emits a reading, marked bad, because silence is indistinguishable from a value that has not changed, and on a trend, the difference between “the sensor reads 0” and “the sensor stopped answering” is the difference between a process problem and an instrument problem.

Alarm evaluation holds its previous state on a bad reading rather than evaluating it. A sensor that fails to zero would otherwise trip every low alarm on the unit and look like a process event.