Free tool
modbus-address-scan — the register a point really asks for
A Modbus point carries two settings that both have to be right and that never check each other: the address, which is a string plus a format enum, and the register type. modbus-address-scan reads the driver out of a Niagara installation with javap, works the boundary values out of the constants it finds, and prints what a point of each format asks for.
- Modbus RTU and TCP
- Reads the shipped jars
- No station needed
- Read-only
- Python 3
- MIT
Install and run
One file, standard library only, and no station involved: it reads jars on disk. It
needs a Niagara installation to read and a javap on the path, which the
JDK that comes with Niagara already provides. Point it at the installation or set
NIAGARA_HOME.
# download it next to wherever you are working
curl -O https://plantroomlabs.com/tools/modbus-address-scan.py
# read the Modbus driver out of an installation
python3 modbus-address-scan.py /opt/Niagara/Niagara-4.15.5.22
What it printed here
This is the whole of one run against the installation on the machine that built this page, pasted by the script that publishes it rather than retyped. The headline is in the first block: a new point reads its address as base 16, so a register documented as 40001 and typed in as 40001 goes out as register 1, and the point reads, goes ok, and is wrong.
$ python3 modbus-address-scan.py $NIAGARA_HOME
Modbus addressing: what a point is before anyone configures it
======================================================================
read from /opt/Niagara/Niagara-4.15.5.22
javap: 1.8.0_504
A point's address is two slots, and these are their defaults:
addressFormat hex (the formats are hex, decimal, modbus)
address "0"
BAddressFormatEnum.DEFAULT is hex as well.
So a freshly made point reads its address as base 16. A number
copied out of a device's Modbus documentation - 40001, 1000, 2000 -
is a different number by the time it reaches the wire, and nothing
in the point says so.
getDataAddress(), the method the poll path calls, by format:
hex Integer.valueOf(address, 16). No offset, no bounds.
decimal Integer.valueOf(address). No offset, no bounds.
modbus parse, then subtract by band, highest band first:
> 40000 -> n - 40001
> 30000 -> n - 30001
> 20000 -> n - 20001
> 10000 -> n - 10001
else -> n - 1
getDataAddressNoModbusAltering() parses and stops - it is the one
the band predicates use, not the one the poll path uses.
The comparisons are if_icmple, so each band's own lower bound falls
through into the band below it. Working the boundaries through:
modbus 40000 -> data address 9999
modbus 30000 -> data address 9999
modbus 20000 -> data address 9999
modbus 10000 -> data address 9999
All 4 of them land on the same register: 9999. Four different
addresses, four different conventions, one register read.
isValid() is the only gate between a typed string and a poll:
modbus format 0 <= n < 50000
every other format n >= 0, and that is the whole test.
Modbus addresses are 16 bits. There is no 65535 anywhere in
isValid(), so a hex or decimal address of any size is valid.
Which register type gets read is a separate property:
determineRegisterType() returns holdingRegister or inputRegister
purely on the point's own regType slot (holding/input). It never looks
at the address or at any of the isModbus*Address predicates.
regType defaults to holding.
So the number and the function code are independent. A modbus-format
address of 30001 - input-register numbering by every convention -
resolves to data address 0 and, on a default point, is read with
the holding-register function code. Nothing objects.
The six isModbus*Address predicates classify an address into a band,
and 3 of the 6 use if_icmple on the lower bound, so the bound itself
is in no band at all. The predicates parse base 10 directly and return
false for any non-modbus format, which makes them silent on exactly the
addresses the default format produces.
What ModbusReadRequest does with the number it is given:
the constructor stores startAddress straight into the field. There
is not one comparison in it.
writeRtu and writeTcp emit two bytes: (a & 65280) >> 8, then a & 255.
That is a 0xFFFF mask applied by omission. Everything above 16 bits is
dropped with no exception, no log line and no status flag.
getRegisterCount by data type: 4, 3, 2, 1 registers per point, so one point's
span can read its address plus 3. The mask above is applied to the
start address, and the point count goes through the same two bytes.
Three consequences worth designing for
----------------------------------------------------------------------
1. The default format is hex. A documented address of 40001 typed into
a new point is 262145, which isValid() accepts, and the frame carries
it as register 1. The point reads, goes ok, and is wrong.
2. Nothing in the chain knows the protocol is 16-bit. The only bound
in isValid() applies to the one format a new point does not have.
3. The address convention and the function code are two properties
that can disagree in silence. A driver or a tool that writes
points should set both, and should refuse the four band boundaries.
Three checks one station settles in an afternoon
----------------------------------------------------------------------
1. Make a point, leave the format alone, type 40001, and watch the
frame: the address bytes should be 00 01.
2. Set the format to modbus and try each of 40000, 30000, 20000, 10000
in turn. If they all read one register, this install behaves
like this one.
3. Set regType to holding, set the format to modbus, and use an address
in the 40001-50000 band. Confirm which function code goes out: the
number does not pick it, the property does.
What it reads, and what that does not cover
It reads modbusCore-rt.jar and modbusTcp-rt.jar with
javap and nothing else. No station is contacted, no device is polled,
and nothing is written anywhere — which is also the limit of what the output is
worth. It is a reading of compiled code, so it tells you what the driver is built to
do rather than what a device did yesterday.
The numbers above came from one installation, 4.15.5.22, and the
output names it. The controller this work usually targets runs
4.14.0.162. A different Niagara version is a different answer, so run
it against yours rather than trusting this page — which is what the three checks
at the end of the output are for. They settle it on a real station in an
afternoon.
The address format property, and the four numbers that mean the same register is the reasoning behind the output, and what a protocol document does not say is why the jar is the thing being read rather than the manual.
The repository
The same file, MIT licensed, at github.com/UsamaIqbal0304/modbus-address-scan. Its README carries the finding and a run of the output, so the repository is checkable without downloading anything. Issues and pull requests are read.
The file you are downloading
Published here so the download is checkable rather than trusted. Both figures
are read off the file served at
/tools/modbus-address-scan.py when this page is built, so they cannot
disagree with it.
| Property | Value |
|---|---|
| File | modbus-address-scan.py |
| Size | 15,514 bytes |
| SHA-256 | cb7aa318ea8bda4af284f69502c527609e937f8d0a4213e811cf7823567e5cb8 |
| Licence | MIT — LICENSE.txt |
| Source | github.com/UsamaIqbal0304/modbus-address-scan |
To check it, on Linux sha256sum modbus-address-scan.py, on macOS
shasum -a 256 modbus-address-scan.py, on Windows
certutil -hashfile modbus-address-scan.py SHA256. A different digest means a
different file — not necessarily a hostile one, but not this one.
The repository holds the same file, byte for byte, together with everything needed to re-run the checks this page's claims rest on — so they can be run rather than read about. Issues and pull requests there are read.
bacnet-sweep
Broadcasts a BACnet/IP Who-Is, tables the devices that answer, and dumps a named device's object list — object name, present value and units — to a table or to CSV. It can encode two BACnet services and no others: Who-Is and ReadProperty.
- BACnet/IP
- Who-Is
- CSV out
mqtt-tap
Subscribes to a broker and prints what is actually on it: the topic tree with a count, a rate, a payload-type guess and the last value per topic, plus the retained topics that stopped updating. It sends five packet types and none of them is PUBLISH.
- MQTT
- Topic tree
- Retained
decoder-check
Runs a LoRaWAN device vendor's payload decoder against your frames in a sealed vm context and reports what a station would actually get back: crashes on a short frame, types that change between uplinks, units glued into values, keys a station has to escape. It reads frames and nothing else - no network, no broker, no network server.
- LoRaWAN
- Decoder
- Sandboxed
ede-check
Reads the EDE import rules out of bacnetEDE-wb.jar with javap, then reports line by line what the shipped parser would reject in your point file and what it would silently default.
- EDE
- Point lists
- Line by line
module-sign-scan
Reads the verification code out of a Niagara installation and prints the four modes, the signature state each one accepts or refuses, and the exact log line a station writes — including the warning that only becomes a refusal when a certificate expires.
- Module signing
- Shipped jars
- No station
bacnet-priority-scan
Reads bacnet-rt.jar with javap and prints the object types Niagara writes through the priority array without asking, the ones it probes with a single ReadProperty, and what a failed probe does to the point for the life of that configuration.
- BACnet
- Shipped jars
- No station
alarm-route-scan
Reads alarm-rt.jar and baja.jar with javap and prints what happens to an alarm between the source and the recipient: one queue, one worker thread, the coalesce key that decides which duplicate is dropped, and why the invocation that lost that collision still reports success.
- Alarms
- Shipped jars
- No station
alarm-recipient-scan
Reads the recipient side of alarm-rt.jar with javap and prints why returning false from sendAlarm drops the alarm silently, what throwing does instead, how long the retry loop runs, and which four properties are the only evidence a site can send you.
- Alarms
- Retry
- No station
schedule-scan
Reads schedule-rt.jar with javap and prints the 90-day scanLimit horizon that turns a far-off change into no change at all, why nextCov steps over a boundary whose value matches, and the one serial uncapped queue every control schedule shares.
- Schedules
- Shipped jars
- No station
workbook-scan
Opens an .xlsx as the zip of XML it is and reports what is in the bytes: formulas saved holding an error, links into files that may be gone, saved queries to one person's mapped drive, approximate VLOOKUPs, hidden sheets, and rules nothing protects. It reads the old binary .xls too.
- Excel
- No install
- JSON out
poll-scheduler-scan
Reads the poll scheduler out of driver-rt.jar and prints the arithmetic: three rate defaults, one point polled per pass, and the bucket size at which the thread stops sleeping and the real cycle time stretches.
- Niagara
- javap
- Read-only
tuning-stale-scan
Reads the tuning policy and the stale branch out of the shipped jars: the default staleTime of zero, the clock it measures, and why an unsolicited device can sit dead with an ok status for as long as the station runs.
- Niagara
- javap
- Read-only
Next step
Send the point that reads the wrong number.
The address, the format and the register type are three settings and the frame is one number. Which of the three is wrong is usually clear from the frame and the point together, and that answer costs nothing either way.