Edge Agent Protocols
The agent reads from fourteen kinds of source. Nine poll live devices: PLCs and controllers over Modbus TCP, Modbus RTU, OPC-UA, Siemens S7, EtherNet/IP, Mitsubishi MELSEC, Omron FINS, and Beckhoff ADS, and building controllers over BACnet/IP. Two subscribe to an MQTT broker (plain MQTT and Sparkplug B). One reads an AVEVA PI historian through PI Web API. Two watch a directory of exported files (CSV and JSON). Whatever the source, each source is tagged with a system_identifier (the System it is, on the portal) and every reading it produces carries a metric_name, a numeric value, a unit, a UTC timestamp, and an optional raw_tag.
A unit is always required. Any reading whose unit is empty, or whose value is not a finite number, is skipped.
Modbus TCP
The Modbus collector connects to a device over TCP and reads the registers you list. On each cycle it opens a connection, reads every configured register, converts the raw value, and closes the connection. If a register read fails, that register is skipped and the rest are still collected.
Configuration
- type: modbus
system_identifier: "CONV-001"
connection:
host: "192.168.1.101"
port: 502
registers:
- address: 100
count: 1
register_type: holding
metric_name: motor_temperature
unit: celsius
scale: 0.1
offset: 0.0
- address: 102
count: 2
register_type: input
metric_name: motor_speed
unit: rpm
scale: 1.0
offset: 0.0
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
host | Yes | 127.0.0.1 | Device IP address or hostname. Must be a valid IPv4/IPv6 address or hostname. |
port | No | 502 | TCP port. Range 1–65535. |
unit_id | No | 1 | The Modbus unit (device) to read, 0–255. Set it when a gateway stands in front of several serial devices. |
registers | Yes | [] | List of registers to read (see below). |
Each entry in registers:
| Key | Required | Default | Description |
|---|---|---|---|
address | Yes | 0 | Register address, 0–65535. |
count | No | 1 | Number of 16-bit registers to read. 1 reads a single register as an integer; 2 reads a big-endian 32-bit float. Only 1 or 2 are supported. |
register_type | No | holding | holding or input. |
metric_name | Yes | unknown | The metric this register maps to. |
unit | Yes | Unit of measure. A register with an empty unit is skipped. | |
scale | No | 1.0 | Multiplier applied to the raw value. |
offset | No | 0.0 | Added after scaling. |
raw_tag | No | Optional label preserved on the reading (e.g. the source register). | |
unit_id | No | the source's | The device to read this register from, when it is not the source's unit_id. |
How values map
Raw Modbus registers are usually integers, so use scale and offset to convert to engineering units:
value = (raw_value * scale) + offset
For example, a temperature register that returns 725 with scale: 0.1 and offset: 0.0 becomes 72.5 celsius. Each register you list becomes one reading per cycle, tagged with the source's system_identifier and the register's metric_name and unit.
Reading Modbus requires the optional Modbus extra. Install it with uv sync --extra modbus (see Installation).
Modbus RTU (serial)
The Modbus RTU collector reads Modbus devices on a serial line (RS-485 or RS-232), usually through a USB adapter. Registers are written exactly as for Modbus TCP, and the serial port stays open between cycles.
One serial port is one bus. List every device on it under one source, each register naming its device by unit_id. The agent refuses a second source on a port another source already reads, because two clients on one line answer each other's frames.
Configuration
- type: modbus_rtu
system_identifier: "TANKS-001"
connection:
port: /dev/ttyUSB0
baudrate: 19200
parity: E
stopbits: 1
unit_id: 1
registers:
- address: 0
metric_name: tank_level
unit: percent
scale: 0.1
- address: 100
count: 2
metric_name: flow_rate
unit: m3/h
unit_id: 7 # device 7 on the same bus
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
port | Yes | The serial device, such as /dev/ttyUSB0. | |
baudrate | No | 19200 | Line speed. Match what the devices are set to; many ship at 9600. |
parity | No | E | N, E, or O. E is the Modbus serial default; many devices ship with N. |
stopbits | No | 1 | 1 or 2. Data bits are always 8. |
unit_id | No | 1 | The device a register reads unless it sets its own, 1–247. |
registers | Yes | List of registers to read, as for Modbus TCP, each optionally with its own unit_id. |
Running in Docker
Pass the serial device into the container and give the agent's user the group that owns it, in docker-compose.override.yml. stat -c %g /dev/ttyUSB0 prints the group id:
services:
agent:
devices:
- /dev/ttyUSB0:/dev/ttyUSB0
group_add:
- "20"
Modbus RTU uses the same optional extra as Modbus TCP, which includes the serial driver: uv sync --extra modbus.
OPC-UA
The OPC-UA collector connects to an OPC-UA server and reads the node values you list. It connects anonymously unless you supply both a username and a password.
Configuration
- type: opcua
system_identifier: "CNC-001"
connection:
endpoint_url: "opc.tcp://192.168.1.100:4840"
username: ""
password: ""
nodes:
- node_id: "ns=2;i=1001"
metric_name: spindle_temperature
unit: celsius
- node_id: "ns=2;s=Vibration.X"
metric_name: vibration_x
unit: mm/s
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
endpoint_url | Yes | opc.tcp://localhost:4840 | Server endpoint. Must use the opc.tcp:// scheme. |
username | No | Username for authenticated servers. Leave empty to connect anonymously. | |
password | No | Password paired with username. | |
nodes | Yes | [] | List of nodes to read (see below). |
Each entry in nodes:
| Key | Required | Default | Description |
|---|---|---|---|
node_id | Yes | The OPC-UA node identifier to read. | |
metric_name | Yes | unknown | The metric this node maps to. |
unit | Yes | Unit of measure. A node with an empty unit is skipped. | |
raw_tag | No | Optional label preserved on the reading (e.g. the node id). |
How values map
Each node's value is read and converted to a number. A value that cannot be converted to a number is skipped and logged. Each node you list becomes one reading per cycle.
Node identifiers follow standard OPC-UA formats, for example:
| Format | Example |
|---|---|
| Numeric | ns=2;i=1001 |
| String | ns=2;s=Temperature.Value |
| GUID | ns=2;g=09087e75-8e5e-499b-954f-f2a9603db28a |
If you set a username without a password (or vice versa), the agent logs a warning and connects anonymously. Reading OPC-UA requires the optional extra: uv sync --extra opcua.
Siemens S7
The S7 collector reads values straight from a Siemens S7 PLC over S7comm (ISO-on-TCP, port 102). There is no OPC-UA server or gateway in between, so an older line without one can be read as it is.
- S7-300 and S7-400 answer out of the box.
- S7-1200 and S7-1500 answer once Permit access with PUT/GET communication from remote partner is ticked in the CPU's protection settings, and only for data blocks with Optimized block access switched off. An optimized block has no fixed byte layout to address. Both are changes to the PLC project in TIA Portal that need a download to the CPU, so agree them with whoever owns the program.
The agent keeps one connection open to each PLC between cycles rather than opening a new one every time: a CPU has only a few connection resources, some reserved for the programming laptop and the HMI. Values that sit close together in the same data block or area are read in a single request. Nothing is ever written to the PLC.
Configuration
- type: s7
system_identifier: "PRESS-005"
connection:
host: "192.168.0.10"
rack: 0
slot: 2
tags:
- address: "DB10.DBD4:REAL"
metric_name: oil_temperature
unit: celsius
- address: "DB10.DBW8:INT"
metric_name: hydraulic_pressure
unit: bar
scale: 0.1
- address: "DB10.DBX0.1:BOOL"
metric_name: running
unit: state
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
host | Yes | PLC IP address or hostname. | |
slot | Yes | The CPU's slot in its rack: usually 2 on an S7-300 and 1 on an S7-1200 or S7-1500. An S7-400's is in its hardware configuration. Range 0–31. | |
rack | No | 0 | The CPU's rack. Range 0–7. |
port | No | 102 | TCP port. |
connection_type | No | pg | Which connection resource the agent takes: pg, op, or basic. pg works everywhere; op or basic leave the programming connection free on a CPU that is short of them. |
tags | Yes | List of values to read (see below). At least one. |
Each entry in tags:
| Key | Required | Default | Description |
|---|---|---|---|
address | Yes | Where the value lives and its type, written as STEP 7 and TIA Portal show it (see below). | |
metric_name | Yes | The metric this value maps to. | |
unit | Yes | Unit of measure. | |
scale | No | 1.0 | Multiplier applied to the raw value. |
offset | No | 0.0 | Added after scaling. |
raw_tag | No | the address | Label preserved on the reading. Defaults to the address, so the portal's tag inventory shows where each value came from. |
Addresses
Write the address the way STEP 7 and TIA Portal show it, with the type after a colon:
| Address | Reads |
|---|---|
DB10.DBD4:REAL | Data block 10, double word at byte 4, as a REAL |
DB10.DBW8:INT | Data block 10, word at byte 8, as an INT |
DB10.DBB2:BYTE | Data block 10, byte 2 |
DB10.DBX0.1:BOOL | Data block 10, byte 0, bit 1 |
DB10:12:LREAL | Data block 10 from byte 12; use this form for 8-byte types |
MW20:WORD, MD4:DINT, M0.3:BOOL | Merker (flag) memory |
IW64:INT, I0.0:BOOL | Inputs (E also works, as in German STEP 7) |
QB1:BYTE, Q4.0:BOOL | Outputs (A also works) |
Supported types: BOOL, BYTE, USINT, SINT, WORD, UINT, INT, DWORD, UDINT, DINT, REAL, LWORD, ULINT, LINT, and LREAL. A BOOL reads as 1 or 0.
The size letter must agree with the type: X is a bit, B a byte, W a word (2 bytes), and D a double word (4 bytes). A REAL addressed as DBW is refused when the agent starts, rather than read as a wrong number every cycle. Strings, timers, counters, and arrays are not read.
How values map
value = (raw_value * scale) + offset
Each tag you list becomes one reading per cycle, stamped with the collection time in UTC. If one address cannot be read (a data block that does not exist, or an address past its end), only that value is skipped. If every value fails, the cycle reports an error.
The agent checks every address, metric name, and unit when it starts, and refuses to start a source with a mistake in it, naming the machine, the tag's position, and its address. Reading S7 requires the optional extra: uv sync --extra s7.
EtherNet/IP (Rockwell / Allen-Bradley)
The EtherNet/IP collector reads tags from Rockwell / Allen-Bradley controllers over CIP, with no gateway or OPC-UA server in between. Like the S7 collector, it keeps one connection open per controller and never writes to it.
It reads two families of controller:
- Logix (
controller: logix, the default): ControlLogix, CompactLogix, GuardLogix, and Micro800, read by tag name as the program shows it. Many tags are packed into a single request. - SLC (
controller: slc): SLC 500 and MicroLogix, read by data-file address over PCCC.
Configuration
- type: ethernet_ip
system_identifier: "LINE-003"
connection:
host: "192.168.1.20"
controller: logix
slot: 0
tags:
- tag: "Program:MainProgram.OilTemp"
metric_name: oil_temperature
unit: celsius
- tag: "Line3_PartCount"
metric_name: part_count
unit: pcs
An SLC 500 or MicroLogix:
- type: ethernet_ip
system_identifier: "PRESS-007"
connection:
host: "192.168.1.30"
controller: slc
tags:
- tag: "F8:2"
metric_name: oil_temperature
unit: celsius
- tag: "N7:0"
metric_name: stroke_count
unit: pcs
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
host | Yes | Controller IPv4 address or hostname. | |
controller | No | logix | logix or slc. |
slot | No | 0 | Logix only: the controller's slot in a ControlLogix chassis. A CompactLogix or Micro800 is reached directly; leave it at 0. |
port | No | 44818 | TCP port. |
tags | Yes | List of tags to read (see below). At least one. |
Each entry in tags:
| Key | Required | Default | Description |
|---|---|---|---|
tag | Yes | A Logix tag name, or an SLC data-file address (see below). | |
metric_name | Yes | The metric this tag maps to. | |
unit | Yes | Unit of measure. | |
scale | No | 1.0 | Multiplier applied to the raw value. |
offset | No | 0.0 | Added after scaling. |
raw_tag | No | the tag | Label preserved on the reading. Defaults to the tag name. |
Tags and addresses
| Controller | Example | Reads |
|---|---|---|
| Logix | Line3_Temp | A controller-scoped tag |
| Logix | Program:MainProgram.Motor.Speed | A program-scoped tag and a member of it |
| Logix | Temps[4], Grid[1,2] | One element of an array |
| Logix | Status.3 | One bit of a DINT |
| Logix | Local:1:I.Data[0] | An I/O module's data |
| SLC | N7:0, F8:2 | An integer or a float in a data file |
| SLC | B3:0/5 | One bit |
| SLC | T4:0.ACC | A timer's accumulated value |
A tag must name a single number. A BOOL reads as 1 or 0. A STRING, a whole UDT, or a whole array is skipped with a warning, and an element count in braces (Temps{10}) is refused when the agent starts.
How values map
value = (raw_value * scale) + offset
Each tag becomes one reading per cycle, stamped with the collection time in UTC. A tag the controller does not have is skipped and logged; if every tag fails, the cycle reports an error.
As with S7, tags, metric names, and units are checked when the agent starts. Reading EtherNet/IP requires the optional extra: uv sync --extra ethernetip.
Mitsubishi MELSEC (MC protocol / SLMP)
The MELSEC collector reads Mitsubishi Q, L, iQ-R, and iQ-F (FX5) CPUs over the MC protocol, also called SLMP: the 3E frame in binary code, over TCP. It keeps one connection open per PLC and sends only read commands, so nothing is ever written to the PLC. The agent carries its own MELSEC client, so nothing extra is installed.
On the PLC, open an MC protocol (SLMP) connection on the CPU's built-in Ethernet port or its Ethernet module in GX Works, set to TCP and binary code, and give its port number to the agent.
Configuration
- type: melsec
system_identifier: "PRESS-007"
connection:
host: "192.168.3.39"
port: 5007
xy_numbering: hex
tags:
- address: D100
metric_name: spindle_speed
unit: rpm
- address: D200
data_type: FLOAT32
metric_name: oil_temperature
unit: celsius
- address: X1F
metric_name: door_open
unit: state
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
host | Yes | PLC IP address or hostname. | |
port | Yes | The port of the PLC's MC protocol connection. There is no standard one. | |
xy_numbering | No | hex | How X and Y are numbered: hex on Q, L, and iQ-R, octal on iQ-F (FX5). |
network_no, pc_no, module_io, station_no | No | 0, 255, 0x03FF, 0 | The route to the CPU. The defaults reach the CPU the connection is to; change them only to reach a CPU through another station. |
tags | Yes | List of devices to read (see below). At least one. |
Each entry in tags:
| Key | Required | Default | Description |
|---|---|---|---|
address | Yes | A device address, such as D100, W1A, X1F, or M10. | |
data_type | No | INT16 (word), BOOL (bit) | For a word device: INT16, UINT16, INT32, UINT32, or FLOAT32. A bit device reads only as BOOL. |
metric_name | Yes | The metric this device maps to. | |
unit | Yes | Unit of measure. | |
scale | No | 1.0 | Multiplier applied to the raw value. |
offset | No | 0.0 | Added after scaling. |
raw_tag | No | the address | Label preserved on the reading. |
Devices
| Device | Kind | Numbering |
|---|---|---|
D, R, ZR, SD, TN (timer current value), CN (counter current value) | word | decimal |
W | word | hexadecimal |
M, L, F, SM | bit | decimal |
B | bit | hexadecimal |
X, Y | bit | hexadecimal, or octal on iQ-F (xy_numbering: octal) |
The 32-bit types take two consecutive devices, low word first, as MELSEC stores them: D200 as FLOAT32 reads D200 and D201. A bit reads as 1 or 0. A device the CPU refuses (one that does not exist) is skipped and logged, and the others are still read; if every device fails, the cycle reports an error.
Omron FINS
The FINS collector reads Omron CJ, CS, and CP CPUs over FINS/UDP, through the CPU's built-in Ethernet port or an Ethernet unit. NJ and NX CPUs answer FINS for variables placed in their CJ-compatible memory with AT addresses. It keeps one socket per PLC and sends only Memory Area Read, so nothing is ever written. The agent carries its own FINS client, so nothing extra is installed.
Configuration
- type: fins
system_identifier: "CNC-002"
connection:
host: "192.168.250.1"
tags:
- address: D100
metric_name: spindle_speed
unit: rpm
- address: D200
data_type: FLOAT32
metric_name: oil_temperature
unit: celsius
- address: CIO0.03
metric_name: door_open
unit: state
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
host | Yes | PLC IP address or hostname. | |
port | No | 9600 | UDP port. |
destination_node | No | the last octet of host | The PLC's FINS node number, 1–254. Required when host is a hostname. |
source_node | No | the last octet of the agent's address | The agent's FINS node number, 1–254. |
tags | Yes | List of addresses to read (see below). At least one. |
Each entry in tags takes address, data_type, metric_name, unit, scale, offset, and raw_tag, as for MELSEC.
Addresses
Write addresses as Omron writes them:
| Address | Reads |
|---|---|
D100 (or DM100) | Data memory word 100 |
CIO0, W10, H5, A100 | A CIO, Work, Holding, or Auxiliary word |
E1_50 | Extended memory bank 1, word 50 |
D100.05, CIO0.03 | One bit of a word, 00 to 15 |
A word reads as INT16 (the default), UINT16, INT32, UINT32, or FLOAT32; the 32-bit types take two words, low word first, as CX-Programmer stores DINT and REAL. A bit reads only as BOOL, 1 or 0.
Node numbers
FINS addresses the PLC and the agent by node number. By default both follow Omron's automatic address generation, where a node's number is the last octet of its IP address. If the PLC's FINS settings use an IP address table instead, set destination_node and source_node to match. An address the CPU refuses is skipped and logged, and the others are still read.
Beckhoff ADS (TwinCAT)
The ADS collector reads TwinCAT 2 and 3 PLC variables by symbol name over ADS (TCP 48898). All of a source's variables are read in one request, over a connection kept between cycles. Nothing is written to the PLC.
Configuration
- type: ads
system_identifier: "PACKER-004"
connection:
host: "192.168.1.50"
ams_net_id: "5.12.34.56.1.1"
tags:
- symbol: MAIN.spindle_speed
metric_name: spindle_speed
unit: rpm
- symbol: GVL.Pumps[2].running
metric_name: pump_running
unit: state
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
host | Yes | PLC IP address or hostname. | |
ams_net_id | No | asked of the PLC | The PLC's AMS Net ID, six numbers such as 5.12.34.56.1.1. |
ams_port | No | 851 | The PLC runtime's AMS port: 851 for TwinCAT 3's first PLC, 801 for TwinCAT 2. |
local_ams_net_id | No | the agent's IP address followed by .1.1 | The agent's AMS Net ID, as the PLC's route names it. Every ADS source on one agent must use the same one. |
tags | Yes | List of variables to read: symbol (such as MAIN.speed or GVL.Pumps[2].count), metric_name, unit, and optionally scale, offset, and raw_tag. |
Routes
The PLC accepts ADS only from a machine it has a route to. Add one in TwinCAT (System, Routes) for the agent's IP address and its AMS Net ID. The agent never adds a route on the PLC itself.
A BOOL reads as 1 or 0. A symbol the PLC does not know is skipped and logged, and is read again once it exists. A string, structure, or array is not a measurement and is skipped. After a download or online change the agent reconnects for a fresh symbol table, so one cycle reports an error.
Reading ADS requires the optional extra: uv sync --extra ads.
BACnet/IP
The BACnet/IP collector reads building controllers (air handlers, chillers, boilers, VAV and lighting controllers) over UDP. Each point is an object and one of its properties, present-value unless you say otherwise. Nothing is written.
Configuration
- type: bacnet
system_identifier: "AHU-001"
connection:
host: "192.168.1.20"
points:
- object: analog-input,1
metric_name: supply_air_temperature
unit: celsius
- object: binary-input,3
metric_name: supply_fan_running
unit: state
- object: analog-value,2
property: high-limit
metric_name: temperature_high_limit
unit: celsius
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
host | Yes | The device's IPv4 address. | |
port | No | 47808 | The device's UDP port. |
local_port | No | any free port | The agent's UDP port, for a firewall rule. Two sources cannot use the same one. |
points | Yes | List of points to read (see below). At least one. |
Each entry in points:
| Key | Required | Default | Description |
|---|---|---|---|
object | Yes | The object's type and instance, as BACnet names them: analog-input,1, analog-value,3, binary-input,1, multi-state-value,4. | |
property | No | present-value | The property to read, such as high-limit. |
metric_name | Yes | The metric this point maps to. | |
unit | Yes | Unit of measure. | |
scale, offset, raw_tag | No | As for the other collectors. |
How values map
Every cycle reads all points with ReadPropertyMultiple. A device that does not offer it (smaller controllers often do not) is read one property at a time from then on. A binary object reads as 1 when active and 0 when inactive, and a multi-state object as its state number. An object or property the device does not have, or a value that is not a number, is skipped and logged. A device that does not answer within about 4 seconds fails the cycle.
Each source reads from its own UDP port, and the device answers to it, so no BACnet broadcast is needed. Devices are reached by IP address: devices behind a BACnet router (MS/TP field buses) are not reached yet.
Reading BACnet/IP requires the optional extra: uv sync --extra bacnet.
MQTT
The MQTT collector subscribes to topics on an MQTT broker instead of polling a device. Use it when your plant's data already flows through a broker: a Unified Namespace, Node-RED, Kepware, or a HiveMQ bridge. The agent connects as an MQTT 3.1.1 client and never publishes anything. If the connection drops it reconnects by itself (after 1 second, doubling to 60) and subscribes again.
Configuration
- type: mqtt
system_identifier: "LINE-3-UNS"
connection:
host: "broker.plant.local"
topics: ["plant/line3/#"]
username: "haltless"
password: "your-broker-password"
units:
"plant/+/press/oil_temp": celsius
Over TLS, with a client certificate:
- type: mqtt
system_identifier: "LINE-3-UNS"
connection:
host: "broker.plant.local"
topics: ["plant/line3/#", "plant/utilities/+/power"]
ca_cert: "/etc/haltless/ca.pem"
client_cert: "/etc/haltless/agent.pem"
client_key: "/etc/haltless/agent.key"
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
host | Yes | Broker IPv4 address or hostname. | |
topics | Yes | Topic filters to subscribe to; a single filter may be written as text. + matches one level and # all the levels below. Each must be a whole level, and # the last one. | |
port | No | 1883 (8883 with TLS) | TCP port. |
username | No | Broker login. Requires password. | |
password | No | Requires username. | |
tls | No | false | Connect over TLS, trusting the host's certificate store. |
ca_cert | No | A CA certificate file to trust instead. Turns TLS on. | |
client_cert, client_key | No | Client certificate and key for mutual TLS. Given together; they turn TLS on. | |
client_id | No | haltless-<system_identifier> | MQTT client ID. Brokers allow one connection per ID, so two agents must not share one. |
qos | No | 0 | Subscription QoS: 0 or 1. |
hold_seconds | No | 600 | How long a value keeps being sent without a new message (see below). 1 to 86400. |
timestamp_field | No | timestamp | The JSON field read as the message's time. |
units | No | Units by topic filter, for values whose message names none. The first matching filter wins. | |
default_unit | No | - | The unit when neither the message nor units names one. |
What a message becomes
- A plain value (
71.4,true,"12.5") is one reading. Itsraw_tagis the topic and its metric name the topic's last level:plant/line3/press/oil_tempis namedoil_temp. - A message that is itself a value object,
{"value": 71.4, "unit": "C", "timestamp": "2026-10-10T12:00:00Z"}, is one reading for the topic too, named the same way, with the object's unit and time. Its other fields are ignored. - Any other JSON object is one reading per number or boolean field, tagged
<topic>#<field>and named after the field. A field can also be an object with avalue, and optionally its ownunitandtimestamp.
For example, this message on plant/line3/press:
{
"timestamp": "2026-10-10T12:00:00Z",
"oil_temp": 71.4,
"running": true,
"pressure": {"value": 3.2, "unit": "bar"}
}
gives three readings: plant/line3/press#oil_temp, plant/line3/press#running (1), and plant/line3/press#pressure in bar.
- Values: numbers, and text that reads as a number.
trueandfalseread as1and0. Other text, arrays, nulls,NaN, and payloads that are not UTF-8 text are skipped, with one warning per tag. Sparkplug B payloads are read by the Sparkplug B source, not this one. - Time: the top-level
timestampfield (ISO 8601, or epoch seconds or milliseconds; a time without a zone is taken as UTC), or else the time the message arrived. A message time more than two minutes ahead of the agent's clock, or more than 3,649 days old (an uptime counter sent astimestamp, a clock reset to 1970 or 2000), is replaced by the arrival time, with one warning per tag. A tag's times only go forward: a new value whose time is not after the tag's last reading takes its arrival time instead, so a device clock running behind the agent's does not make the series jump back. - Names: a blank field name, or a name or unit holding a NUL character, is skipped (a bad unit is ignored), and a topic ending in a blank level is named after its last named one. Haltless would refuse such a reading, and with it the whole request.
- Unit: the message's own, else the first matching filter in
units, elsedefault_unit.
Each cycle
Each collection cycle sends the latest value of every tag. When several messages arrive for a tag between cycles, only the newest is sent.
A topic that publishes only when its value changes still reports: its last value is sent again every cycle, stamped with that cycle's time, until hold_seconds pass without a new message. A source can hold more tags than batch_size: each cycle sends full batches while half the collection interval lasts, and buffers the rest locally, to go out in requests of up to 1,000 readings. Then it stops, so a machine whose publisher has gone quiet goes offline. Retained messages give every tag a value as soon as the agent subscribes.
While the broker is unreachable, values already held keep going out until they age past the hold; after that, each cycle reports the source as disconnected.
The topics appear on the portal as the System's unassigned tags. Assign them to machines as you would any other source's tags.
Topic filters, the login, the TLS files, qos, and hold_seconds are checked when the agent starts. A message larger than 64 KiB is ignored, and one source holds at most 5,000 tags: further new tags are dropped with one warning, so narrow topics instead. Reading MQTT requires the optional extra: uv sync --extra mqtt.
Sparkplug B
The Sparkplug B collector reads the edge nodes and devices of one Sparkplug B group from an MQTT broker. The broker settings (host, port, tls, ca_cert, client_cert, client_key, username, password, client_id, qos, hold_seconds, units, default_unit) are the MQTT source's; the topics follow from the Sparkplug ids. The agent only subscribes: it never publishes, so it sends no rebirth requests or commands.
Configuration
- type: sparkplug_b
system_identifier: "LINE-3-SPB"
connection:
host: "broker.plant.local"
group_id: "Plant1"
edge_node_id: "Line3"
units:
"Line3/+/Pressure": bar
Connection keys
Besides the MQTT broker settings:
| Key | Required | Default | Description |
|---|---|---|---|
group_id | Yes | The Sparkplug group. | |
edge_node_id | No | every node in the group | One edge node. |
device_id | No | the node and all its devices | One device: of edge_node_id, or of every node without it. The node's own metrics are then not read. |
What a metric becomes
Each metric is a tag named <edge_node_id>[/<device_id>]/<metric name>. Its unit is the birth certificate's engUnit property when the node sends one, else the first matching entry in units, else default_unit. Its time is the metric's own timestamp, or the message's, with the same limits as MQTT.
Births are remembered, so data messages that carry only a metric's alias are named from the last birth. A value by an alias no birth has named is skipped until the node's next birth. A death (NDEATH, DDEATH) stops that node's or device's values being reported at once. Integers, floats, and booleans are read (a boolean as 1 or 0); strings, data sets, and templates are skipped, as are historical (store-and-forward) metrics, since each cycle sends a tag's latest value.
Sparkplug B uses the MQTT optional extra: uv sync --extra mqtt.
AVEVA PI Web API
The PI Web API collector reads current values from an AVEVA PI System: PI points and AF attributes, over HTTPS. It reads what PI already records, with PI's own timestamps, and sends only GET requests, so nothing in PI is changed. Nothing extra is installed.
Configuration
- type: pi_web_api
system_identifier: "PI-PLANT1"
connection:
base_url: "https://pi.plant.local/piwebapi"
username: "svc-haltless"
password: "${PI_PASSWORD}"
points:
- path: '\\PISRV01\Reactor1.Temperature'
metric_name: reactor_temperature
- path: '\\AFSRV01\Plant\Line3\Press1|Pressure'
metric_name: press_pressure
unit: bar
Write paths in single quotes, so YAML leaves their backslashes alone.
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
base_url | Yes | The PI Web API address. Must use https://, except for a server on the agent's own machine. | |
username, password | No | HTTP Basic login, given together. Leave both out for anonymous access. | |
ca_cert | No | A CA certificate file, when the server's certificate comes from a private CA. | |
points | Yes | List of points to read (see below). At least one. |
Each entry in points:
| Key | Required | Default | Description |
|---|---|---|---|
path | Yes | A PI point (\\SERVER\tag) or an AF attribute (`\AFSERVER\Database\Element | |
metric_name | Yes | The metric this point maps to. | |
unit | No | PI's unit | Unit of measure. Without it, PI's own unit is used. |
scale, offset, raw_tag | No | As for the other collectors. |
How values map
Each path is resolved once, and every cycle reads all current values in one request. A path PI cannot find is skipped with a warning and tried again each cycle. A value keeps PI's timestamp: one ahead of the agent's clock takes the agent's time, and one older than Haltless accepts is skipped. Bad-quality values and system digital states (Shutdown, I/O Timeout) are skipped; any other digital state reads as its number.
Basic authentication must be enabled on the PI Web API server (Kerberos is not supported), for an account with read access to the points.
CSV files
The CSV collector watches a directory for .csv files and turns each row into a reading. It reads files incrementally: it remembers how far into each file it has read, so on the next cycle it only picks up newly appended rows. This makes it a good fit for systems that append to a rolling export file.
Configuration
- type: csv
system_identifier: "PRESS-003"
connection:
directory: "/data/press-exports"
delimiter: ","
col_timestamp: "timestamp"
col_metric: "metric_name"
col_value: "value"
col_unit: "unit"
col_raw_tag: ""
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
directory | Yes | "." | Directory to watch for .csv files. Must be a project-owned path, not a system directory. |
delimiter | No | , | Column delimiter. |
col_timestamp | No | timestamp | Column holding the reading timestamp. |
col_metric | No | metric_name | Column holding the metric name. |
col_value | No | value | Column holding the numeric value. |
col_unit | No | unit | Column holding the unit. |
col_raw_tag | No | Optional column mapped to raw_tag. |
File format and behavior
Files must have a header row naming the columns above. Example:
timestamp,metric_name,value,unit
2026-04-04T10:30:00Z,temperature_celsius,72.5,celsius
2026-04-04T10:30:10Z,temperature_celsius,72.8,celsius
- Timestamps should be ISO 8601. If a timestamp has no timezone offset, it is assumed to be UTC.
- Rows with a missing unit, a non-numeric value, or missing required columns are skipped and logged; the rest of the file is still processed.
- Files whose names begin with a dot are ignored.
JSON and JSONL files
The JSON collector watches a directory for .json and .jsonl files and turns each object into a reading. Unlike the CSV collector, it treats each file as a one-shot export: a file is processed once and not read again, so drop each new export in as a new file.
- A
.jsonfile may contain a single object or an array of objects. - A
.jsonlfile has one JSON object per line.
Configuration
- type: json
system_identifier: "CONVEYOR-004"
connection:
directory: "/data/conveyor-exports"
key_timestamp: "timestamp"
key_metric: "metric_name"
key_value: "value"
key_unit: "unit"
key_raw_tag: ""
Connection keys
| Key | Required | Default | Description |
|---|---|---|---|
directory | Yes | "." | Directory to watch for .json/.jsonl files. Must be a project-owned path, not a system directory. |
key_timestamp | No | timestamp | Object key holding the reading timestamp. |
key_metric | No | metric_name | Object key holding the metric name. |
key_value | No | value | Object key holding the numeric value. |
key_unit | No | unit | Object key holding the unit. |
key_raw_tag | No | Optional key mapped to raw_tag. |
File format and behavior
[
{"timestamp": "2026-04-04T10:30:00Z", "metric_name": "belt_speed", "value": 1.8, "unit": "m/s"},
{"timestamp": "2026-04-04T10:30:10Z", "metric_name": "belt_speed", "value": 1.9, "unit": "m/s"}
]
- Timestamps follow the same rule as CSV: ISO 8601, and a value with no timezone offset is assumed to be UTC.
- Objects with a missing unit, a non-numeric value, or a missing required key are skipped and logged.
- Files whose names begin with a dot are ignored, and very large files are skipped to protect the agent.
How the agent sends readings
Whichever collectors you use, the agent batches readings and sends them to the Haltless ingestion endpoint over HTTPS, authenticated with your API key. A batch looks like this:
{
"readings": [
{
"machine_identifier": "CNC-001",
"timestamp": "2026-04-04T10:30:00Z",
"metric_name": "temperature_celsius",
"value": 72.5,
"unit": "celsius",
"raw_tag": "ns=2;i=1001"
}
]
}
Haltless responds with a per-reading result (accepted_count, rejected_count, and any errors), so a single malformed reading never blocks the rest of the batch. unassigned_count says how many accepted readings belong to tags not yet assigned to a machine: they are stored, and monitored once someone assigns the tag on the System's page. For the full ingestion contract, see the Sensor data API.
Agentless alternative: direct REST ingestion
You don't have to run the agent to get data into Haltless. If it's easier for your system to push readings itself, you can send the same batch payload directly to the Haltless ingestion endpoint over HTTPS with your API key. No local software is required. This is a good fit when data already lives in a system that can make outbound HTTP requests.
See Direct ingestion for the endpoint, payload, and examples.
Next steps
- Configuration: top-level keys, buffering, and TLS
- Troubleshooting: connection, value, and timestamp problems for each protocol
- Direct ingestion: the agentless REST alternative