Beta

Modbus TCP to the cloud: energy meters and older PLCs

Energy meters, heat meters, inverters and the PLCs that came before OPC UA mostly speak one thing: Modbus. Tag Historian reads it through the same collector you run for MQTT and OPC UA, next to the equipment, with one explicit list of registers and the same disk buffer for when the internet drops.

This connector is Beta, and here is exactly what that means: it has been tested against a Modbus TCP simulator - every data type, every word order, devices dropping out and coming back - but not yet against real hardware. If you point it at a meter or a PLC, we would like to hear how it went.

Last updated

A collector next to the device

A Modbus device answers whoever connects to its port 502, with no password, so it belongs on a network nothing outside can reach. The collector runs on that network and connects outward in both directions: to the device on the LAN, and over HTTPS to Tag Historian. No port forwarding, and no meter exposed to the internet. It only ever reads; it never writes a register or a coil.

1. Get an API key

Create an account and an API key first. The free plan is €0 and takes an email address — no card — and gives you 5 tags and 50,000 readings a day. Then create the key on the Settings page in Tag Historian Explorer — and the scope you give it is not a detail.

The key is shown once. Copy it straight into the container’s environment — if you lose it, generate a new one rather than hunting for the old one.

2. Start it once to get a config file

The collector reads its configuration and keeps its offline buffer in one directory, /config, on a Docker volume. Start it once on a new, empty volume and it writes a complete, commented example configuration there, says what to edit, and stops:

first start
docker run --name tag-collector -v tag-collector:/config ghcr.io/softi-dev/tag-historian-clients/collector:latest

It ends with exit code 78, “configuration required”. That is the expected result here, not a failure: nothing has connected anywhere yet. The stopped container stays behind so the next step can reach the volume through it.

3. Edit the config

Copy the file out of the volume, so you can edit it with any editor:

copy out
docker cp tag-collector:/config/appsettings.json appsettings.json

In appsettings.json, find ModbusDevices at the end of the file and remove the leading // from the lines between its brackets, as the comment above it says. Then fill in your device's Host and its UnitId, and one Tags row per value, with the register type, address and data type from the device's manual - how the manual's numbers map is below. Then delete the example entries in MqttSources and OpcServers if you are not using them. Every value still in <angle brackets> is a placeholder; everything else is a default, with a comment saying what it does. Trimmed to what matters, the edited file comes down to this:

/config/appsettings.json
{
  "TagHistorian": {
    "ApiUrl": "https://api.taghistorian.com",
    "ApiKey": ""
  },
  "MqttSources": [],
  "OpcServers": [],
  "ModbusDevices": [
    {
      "Name": "meter",
      "Host": "192.168.1.30",
      "UnitId": 1,
      "PollIntervalMs": 1000,
      "Tags": [
        { "TagName": "meter.voltage", "RegisterType": "Input", "Address": 0, "DataType": "Float32", "Units": "V" },
        { "TagName": "meter.current", "RegisterType": "Input", "Address": 2, "DataType": "Float32", "Units": "A" },
        { "TagName": "meter.power",   "RegisterType": "Input", "Address": 4, "DataType": "Float32", "Units": "W" },
        { "TagName": "meter.energy",  "RegisterType": "Holding", "Address": 100, "DataType": "UInt32", "Scale": 0.01, "Units": "kWh" }
      ]
    }
  ]
}

Leave ApiKey empty. The key goes into the container's environment in the next step, so it is never stored on the volume. Then copy the file back and remove the first-start container:

copy back
docker cp appsettings.json tag-collector:/config/appsettings.json
docker rm tag-collector

If you run docker with sudo on Linux, use it for all of these, and edit the copied file with sudo as well: a file docker copied out as root belongs to root.

4. Run it

docker run
docker run -d --name tag-collector --restart unless-stopped -v tag-collector:/config -e TagHistorian__ApiKey="<your-api-key>" ghcr.io/softi-dev/tag-historian-clients/collector:latest

Follow it with docker logs -f tag-collector. Without a key the collector collects nothing and says so in the log, and a key still reading <your-api-key> stops it with exit code 78 and the name of the variable. Both on purpose: a missing key is a mistake to fix now, not an outage to wait out. Left to run, the collector would have every upload refused with a 401, which it treats like a revoked key, holding the readings on disk and retrying, so a forgotten key would look like a running collector until the buffer reached its 7-day / 512 MiB bound and began dropping the oldest readings.

A placeholder left in the file also stops it with exit code 78 and the name of the setting, and so does a file that no longer parses after the edit, with the line and position where the parser gave up; your file is never overwritten. Under --restart unless-stopped such a container shows as Restarting (78) in docker ps, and the log says why. To change the configuration later, copy it out and back in the same way and run docker restart tag-collector.

Or with compose, once steps 2 and 3 have created the volume and put the config in it:

docker-compose.yml
services:
  collector:
    image: ghcr.io/softi-dev/tag-historian-clients/collector:latest
    container_name: tag-collector
    restart: unless-stopped
    volumes:
      - tag-collector:/config
    environment:
      TagHistorian__ApiKey: "<your-api-key>"

volumes:
  tag-collector:
    external: true

Every command here is one line, so it pastes the same into bash, zsh or PowerShell. The image is built for linux/amd64 and linux/arm64: the same commands run on a PC, on Docker Desktop for Windows or macOS, and on an ARM box with a 64-bit OS such as a Raspberry Pi or an industrial gateway.

Updating the collector

A container keeps the image it was created from, so docker restart tag-collector starts the same collector again. To move to the newest, pull it and create the container again on the same volume; the configuration and the readings not yet sent stay where they are:

update
docker pull ghcr.io/softi-dev/tag-historian-clients/collector:latest
docker stop tag-collector
docker rm tag-collector
docker run -d --name tag-collector --restart unless-stopped -v tag-collector:/config -e TagHistorian__ApiKey="<your-api-key>" ghcr.io/softi-dev/tag-historian-clients/collector:latest

with your key in place of <your-api-key>, as before. With compose, docker compose pull and then docker compose up -d do the same. Tag files, browse and Modbus devices need collector 1.0.47 or later: an older one ignores TagsFile and ModbusDevices without a word, so update a collector you started before then.

On Windows, without Docker

Where IT does not allow Docker, on the SCADA PC or a small plant's Windows server, the same collector runs as a Windows service: one self-contained program for 64-bit Windows 10, 11 or Server 2016 and later, with nothing else to install. It reads the same appsettings.json, from C:\ProgramData\Tag Historian\Collector instead of a volume, and keeps its offline queue and certificates beside it.

Download the Windows zip and check it against the SHA-256 checksum published beside it.

In an elevated PowerShell, in the folder the zip was extracted to:

install
powershell -ExecutionPolicy Bypass -File .\install.ps1

install.ps1 copies the program to C:\Program Files\Tag Historian\Collector and registers the service TagHistorianCollector: automatic start, restarted a minute after a crash, running as its own virtual account NT SERVICE\TagHistorianCollector, which is granted write access to its data folder and nothing else. The data folder is readable by administrators and the service only, because your API key goes in it. Then it starts the service once, which writes the example configuration and stops, as the container's first start does.

Edit the file from the same elevated prompt, and put your Write key in ApiKey: on Windows the key goes in the file, because a service does not see a system environment variable set after Windows started. Then start the service and follow its log:

configure and start
notepad "C:\ProgramData\Tag Historian\Collector\appsettings.json"
Start-Service TagHistorianCollector
Get-Content -Wait -Tail 20 "C:\ProgramData\Tag Historian\Collector\logs\collector.log"

logs\collector.log in the data folder carries the lines docker logs would show, and warnings and errors also go to the Application event log under Tag Historian Collector. An empty ApiKey, a placeholder left in, or a file that no longer parses stops the service with the reason in both places, and it stays stopped instead of restarting every minute.

To update, run install.ps1 from a newer zip's folder the same way: it stops the service, replaces the program and starts it again, and keeps the data folder. The zip's name carries the collector's version; tag files, browse and Modbus devices need 1.0.47 or later.

The program and scripts are not signed yet. Started from Explorer, the program gets “Windows protected your PC”: choose More info, then Run anyway. The -ExecutionPolicy Bypass above is what lets the unsigned install script run, for that one run. On Windows 11 with Smart App Control turned on, unsigned programs are blocked outright; use the container there until the collector is signed.

Addresses: 40001 is Address 0

Address is the 0-based address the protocol sends. Most manuals print 1-based register numbers with the register type as the first digit instead. Subtract the first number of the range:

In the manualRegisterTypeAddressExample
00001 - 09999Coilnumber - 100017 → Address 16
10001 - 19999DiscreteInputnumber - 1000110003 → Address 2
30001 - 39999Inputnumber - 3000130013 → Address 12
40001 - 49999Holdingnumber - 4000140101 → Address 100
400001 - 465536Holdingnumber - 400001401025 → Address 1024

Some manuals give the protocol address directly, often in hex (0x0064): that is already the Address, written in decimal (100). If every value looks like its neighbour's, the addresses are one off.

Data types, word order and byte order

A register is 16 bits. Wider values span several registers, starting at Address:

DataTypeRegistersStored as
Bool1 (or one coil)true / false, charted as 1 and 0
Int16, UInt161an exact integer
Int32, UInt322an exact integer
Int644an exact integer, all 64 bits of it
Float322a number
Float644a number

Coils and discrete inputs are always Bool. A Bool read from a register is true when the register is not zero, or set Bit (0 to 15, 0 being the least significant) to read one bit of a status word. Several tags can read the same register.

The specification says how the two bytes of a register travel, and nothing about how a 32 or 64 bit value is split across registers, so devices disagree. Two settings cover every layout in use, on the device for all its tags or on one tag:

Often calledWordOrderByteOrder
ABCD, big-endian (the default)HighWordFirstBigEndian
CDAB, word swapLowWordFirstBigEndian
BADC, byte swapHighWordFirstLittleEndian
DCBA, little-endianLowWordFirstLittleEndian

A Float32 that reads as something like 1.4e-41 or -2.3e+38 is almost always in the wrong word order: try LowWordFirst first. Text stored in registers is not read.

Scale, Offset and Deadband

Many devices store a value as an integer in tenths or hundredths. The stored value is raw × Scale + Offset: a voltage register holding 2305 with Scale 0.1 is stored as 230.5. An integer with no scale stays an exact integer, so a 64-bit energy counter keeps its last digit.

Deadband is a distance in those stored units: a change no larger than it is not sent. 0.5 on a voltage means a reading goes out when it has moved by more than half a volt from the last one sent.

The collector applies it, before anything is sent, and it goes no further. What Tag Historian keeps of what arrives is decided by the tag's own deadband, a different kind of number: a fraction of the last stored value, 0.001 (a move over 0.1%) for every new tag, which the OPC UA guide shows how to change.

Polling, and what is sent

Modbus has no subscriptions, so the collector asks: every PollIntervalMs it reads every tag of the device. What it sends is another matter. A tag sent at every poll of a one-second interval would be 86 400 readings a day for a value that may never move, so a reading is sent only when the value has changed - by more than its Deadband, if it has one - or when it becomes readable again after an outage. That is what an OPC UA subscription does on the server, and it makes the two cost the same for the same plant.

Each reading is timestamped when the device's answer arrives. From collector 1.0.48, a value that does not move is still sent again every 5 minutes (HeartbeatSeconds, 300 by default), so a “source went quiet” alert on a setpoint that holds all day fires when the device stops answering, not because the setpoint held. That costs at most 288 extra readings a day for any tag, under 3% of the readings a day your plan allows for each tag, and none for a tag that changes at least every 5 minutes. Set it to 0 to send changes only, or higher to send fewer. An older collector sends changes only unless you set it.

Fewer round trips

Neighbouring tags of the same register type are read in one request, up to 125 registers or 2000 coils, the most one Modbus request carries; MaxRegistersPerRequest lowers that for a device that takes less. MaxGap lets a request read across a few unused registers between two tags. If a device refuses a request that holds several tags - an address it does not have among them - the collector reads those tags one request each from then on, so one wrong address costs one tag and not its neighbours. That takes a device that says no, as the specification asks; one that stays silent instead is covered below.

When a device stops answering

Nothing is invented. A tag that cannot be read gets one outage marker: its last good value, with an OPC UA status code saying why, timestamped when the failure was seen - the same way the OPC UA collector stores a Bad sample, so the gap shows in the history instead of being drawn over: the chart leaves it out unless you choose to show bad quality, and an Excel report leaves its hours empty. Until a tag has had one good reading there is nothing to carry, so no marker is stored at all.

StatusWhen
BadNoCommunicationNo connection to the device, or a gateway reporting that its device did not answer (exception 10 or 11).
BadTimeoutConnected, but no answer within TimeoutMs. A wrong UnitId behind a gateway looks exactly like this.
BadConfigurationErrorThe device refused the request: an address, function or count it does not support (exception 1, 2 or 3).
BadDeviceFailureThe device reported a failure of its own (exception 4).
BadA float register holding NaN or infinity - the device saying it has no value.

The collector reconnects on its own, forever, backing off from ReconnectDelaySeconds to MaxReconnectDelaySeconds. Each device is polled on a thread and a connection of its own, so a device that is switched off only ever delays itself: the others keep their schedule. An idle connection the device closed is reopened at the next poll without a marker, because nothing was lost.

A device that answers some requests and not others

A device asked for a register type it does not have should refuse with an exception, and then only those tags are lost. Some devices, and gateways in front of them, say nothing at all instead. To the collector that silence looks exactly like a device that has stopped, so every tag of the device is marked BadTimeout and it is retried on the backoff, even though other requests would be answered. The warning names the request that went unanswered - no answer ... to a read of Input 0-5. If a device you can reach has all its tags at BadTimeout, check that request's RegisterType against the manual, and remove or correct the tags that ask for it.

Tags from a CSV file

A register map is a table, so it can be one. Point TagsFile at a CSV file in the collector's folder (/config in the container, beside appsettings.json) and its rows are read as well as the JSON Tags. Where the two overlap, the columns are named as in the OPC UA tag file - TagName, Units, Description, Enabled - with RegisterType, Address and DataType in place of the NodeId:

meter.csv
TagName,RegisterType,Address,DataType,Scale,Units,Description
voltage,Input,0,Float32,,V,Phase 1 voltage
current,Input,2,Float32,,A,Phase 1 current
power,Input,4,Float32,,W,Active power
energy,Holding,100,UInt32,0.01,kWh,"Import energy, total"
  • TagName and Address are required. RegisterType defaults to Holding, DataType to UInt16 (Bool for coils and discrete inputs). Scale, Offset, WordOrder, ByteOrder, Bit, Deadband and Enabled are optional columns, meaning what they mean in JSON.
  • Deadband is a distance in the tag's own units, as in JSON: a Deadband of 0.01 on the energy row would be a hundredth of a kWh, never 1 %. The OPC UA tag file reads a Deadband column as CompressionDeadband, a distance in units too, which the OPC UA server filters on instead of the collector. Neither sets the tag's own deadband in Tag Historian (above).
  • Columns are found by their header, in any order and any case; Unit is read as Units. A column the collector does not read, a column of notes say, is reported once and ignored.
  • Saved from Excel as it comes: comma, semicolon or tab separators, a decimal comma in Scale, quotes around a value with a comma or a line break in it. The file must be UTF-8, which Excel writes as CSV UTF-8. Blank lines and lines starting with # are skipped, so a # in front of a row takes it out, whichever column comes first.
  • A row that cannot be used - an unknown data type, an address that is not a number, a tag name Tag Historian would refuse, a tag name an earlier row already has - is logged as an error with its line and column, lettered as in Excel, and the other rows are collected. A row with Enabled false is left out quietly. The OPC UA tag file is read by the same rule.
  • A file the collector cannot read at all, or in which not one row can be used, stops it at start with exit code 78 and the reason for each row: a file in another character set, say, is refused with the way to save it rather than guessed at, because a wrong guess would rename tags with å or ° in them. That is collector 1.0.48 and later; an older one starts without the file's tags, with the reason in the log.
  • A tag name is collected from one place only. If an OPC UA server or another device already collects it, this row is skipped with an error naming both places. TagPrefix, below, keeps identical meters apart. Before collector 1.0.48, only the other devices are checked.

Identical meters can share one file. TagPrefix goes in front of every tag name from a device, so the same rows become two sets of tags:

two entries in ModbusDevices
{ "Name": "meter1", "Host": "192.168.1.31", "TagPrefix": "meter1.", "TagsFile": "meter.csv" },
{ "Name": "meter2", "Host": "192.168.1.32", "TagPrefix": "meter2.", "TagsFile": "meter.csv" }

To copy the file onto the container's volume, use docker cp the way the config file goes in: docker cp meter.csv tag-collector:/config/meter.csv, then restart the collector. The file is read when the collector starts.

Gateways and unit ids

Behind a Modbus TCP-to-RTU gateway, the Host is the gateway and UnitId is the address of the device on the serial line - each device is an entry of its own, with its own connection. Many gateways accept only a handful of connections at once, so check yours before giving it dozens of entries, and allow for the serial line in TimeoutMs and PollIntervalMs: 125 registers at 9600 baud take about 300 ms on the wire alone.

When the internet drops

Readings go through the same disk buffer as every other source the collector has, 7 days or 512 MiB by default, oldest dropped first and logged. The MQTT guide has the details, which are the same buffer's, and where your data lives says what is guaranteed where.

Settings

Per device in ModbusDevices:

SettingDefaultNotes
NamemodbusAppears in every log line about this device.
Host–Required. The device, or its TCP gateway.
Port502—
UnitId10-255. Behind a gateway, the device’s address on the serial line.
PollIntervalMs1000How often every tag is read. At least 100.
TimeoutMs2000How long to wait for the connection and for each answer, not counting the DNS lookup of a host name.
ReconnectDelaySeconds1First wait before reconnecting; doubles from there.
MaxReconnectDelaySeconds60The longest wait between attempts. It never gives up.
MaxRegistersPerRequest125Lower it for a device that refuses long reads.
MaxCoilsPerRequest2000The same for coils and discrete inputs.
MaxGap0Unused registers a request may read across between two tags.
WordOrderHighWordFirstOr LowWordFirst. For every tag that does not set its own.
ByteOrderBigEndianOr LittleEndian. For every tag that does not set its own.
HeartbeatSeconds300Resend an unchanged value this often. 0 sends changes only, the default before collector 1.0.48.
TagPrefix–Put in front of every tag name from this device.
TagsFile–A CSV file of tags, relative to the collector’s folder.
Tags–One row per tag. Required unless TagsFile lists them.

Per tag in Tags, or per row of the CSV file:

SettingDefaultNotes
TagName–Required. Starts with a letter; letters, digits and . _ - only.
RegisterTypeHoldingHolding, Input, Coil or DiscreteInput.
Address–Required. 0-based: 40001 in a manual is Address 0.
DataTypeUInt16Bool, Int16, UInt16, Int32, UInt32, Float32, Int64 or Float64. Bool for coils.
Bit–For a Bool from a register: which bit, 0-15.
WordOrder / ByteOrder–Override the device’s.
Scale / Offset1 / 0Stored as raw × Scale + Offset. Not for Bool.
Deadband0Changes no larger than this, in the tag’s own units after scaling, are not sent.
Units–Stored as tag metadata. Unit works as a CSV header too.
Description–Stored as tag metadata.
EnabledtrueKeep the row, stop collecting it.

Trying it without hardware

Any Modbus TCP server will do, and there are free simulators for Windows and Linux and as containers: put a few values in its holding registers, point Host at it, and watch them arrive. Our own tests run the collector against a Modbus TCP server in the test process, which is also how it was proven - and why it is still marked Beta.

What this is not

  • We do not write to your devices. Only read requests are ever sent.
  • Not Modbus RTU over a serial port. RS-485 devices are read through a Modbus TCP gateway.
  • No discovery. Modbus has no way to ask a device what it holds, so every tag is a row you write - and one row is exactly one tag against your plan.
  • Not proven on hardware yet. It is Beta until it has been. Tell us what you ran it against.

Common questions

Does the collector write to my Modbus devices?
No. It only ever sends read requests - read coils, read discrete inputs, read holding registers and read input registers. There is no setting that makes it write.
Can it read Modbus RTU over RS-485?
Through a Modbus TCP-to-RTU gateway, yes: point the collector at the gateway and give each RTU device an entry of its own with its unit id. A serial port on the collector machine itself is not supported.
My manual lists register 40001. What goes in Address?
Address 0, with RegisterType Holding. Address is the 0-based protocol address: subtract 40001 from a 4xxxx holding register, 30001 from a 3xxxx input register, 10001 from a 1xxxx discrete input and 1 from a 0xxxx coil.
Why does a Float32 come out as a tiny or enormous nonsense number?
The device stores its two registers the other way round. Set WordOrder to LowWordFirst on that tag, or on the device if all its values do it. If that is still wrong, the byte order is swapped as well: add ByteOrder LittleEndian.

Try it on the free plan

5 tags, 50,000 readings a day, 14 days at full resolution and ten years of hourly summaries. €0, no card.