Jump to content

ModbusBridge

From Commander4j
Revision as of 22:10, 15 July 2026 by Dgarratt (talk | contribs) (Updated by push_wiki.py)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

The Commander4j Modbus Bridge (util_modbusBridge) is a headless service that lets applications without Modbus/TCP client capability set and query the values of a remote Modbus server over a simple REST API. It connects to one Modbus server, polls a fixed list of named points (configured in XML), keeps the latest values in memory, and serves them over HTTP. It also provides a live web UI to view and change values, and a second page to tail the log.

Unlike the interactive Commander4j Modbus Client, the Bridge runs unattended: it auto-reconnects when the link drops and is packaged to run as an operating-system service. It reuses the same Modbus engine as the ModbusClient and is a natural companion to the ModbusServer.

If you are new to Modbus, the Modbus primer on the Modbus Client page explains the terms (client/server, unit ID, and the four data tables) used throughout this page.

Purpose

The Modbus Bridge is useful when:

  • An application or script that cannot speak Modbus needs to read or write a PLC or other Modbus/TCP device over plain HTTP/JSON
  • A remote Modbus server's values should be exposed on a network as named points rather than raw addresses
  • An unattended, always-on bridge is needed that survives link drops and restarts as a managed service
  • A browser-based view of live values, with inline editing and a live log, is wanted without installing a desktop tool
  • A calling application must keep working, unchanged, while the physical Modbus device is out of service (see Simulated points)

The Bridge is a generic tool — any application that can issue HTTP requests can use it. Within the Commander4j suite, for example, the web Issue application (c4j_web_Issue) can use it to pulse a PLC relay when a pallet is scanned in the wrong lane, but nothing about the Bridge is specific to that (or any other) application.

Source Code and Releases

The Modbus Bridge is open source and hosted on GitHub:

Running the Modbus Bridge

The recommended way to install the Modbus Bridge is to download a native installer for your platform, either from the Downloads page or from the GitHub releases page. Native installers are provided for Windows, macOS and Linux. Each installer bundles its own Java 25 runtime, so no separate Java installation is required.

As a service

The native installer registers and starts the Bridge as an operating-system service — launchd on macOS, a Windows service, or a systemd / init unit on Linux. The service runs with its working directory set to the installation directory, so all the relative paths below resolve correctly. Stopping the service triggers an ordered shutdown so that teardown events still reach the log file.

Running from the jar

Alternatively the Bridge can be launched from its distribution folder:

  • ./start_modbusBridge.sh (macOS/Linux) or start_modbusBridge.cmd (Windows), or
  • ant run to launch from compiled classes, or
  • directly with Java:
java -Dlog4j2.shutdownHookEnabled=false -Dlog4j2.configurationFile=xml/config/log4j2.xml -jar modbusBridge.jar

modbusBridge.jar is a thin jar — it references the dependency jars in lib/ via its manifest, so it must be run from the project root with lib/, xml/, web/ and logs/ alongside it. Any Java 25 runtime on the PATH will do; a bundled JRE is not required when run this way.

The two -D system properties matter, and the start scripts set them for you:

  • -Dlog4j2.shutdownHookEnabled=false — lets the Bridge's own shutdown hook stop log4j2 last, so teardown events still reach the log file the tail page reads.
  • -Dlog4j2.configurationFile=xml/config/log4j2.xmllog4j2.xml lives under xml/config/, not on the classpath, so log4j2 must be pointed at it explicitly.

Configuration

All configuration is in xml/config/config.xml:

<config>
  <modbus>
    <ip>127.0.0.1</ip>
    <port>1502</port>
    <id>1</id>
    <pollIntervalMs>1000</pollIntervalMs>
    <maxHoldMs>60000</maxHoldMs>
  </modbus>
  <webserver>
    <ip>0.0.0.0</ip>
    <port>8080</port>
  </webserver>
  <restapi>
    <id name="LANEA" point="pump_run" caseSensitive="false"/>
    <id name="LANEB" point="pump_run" caseSensitive="false"/>
  </restapi>
  <points>
    <point name="pump_run"   kind="COIL"             address="0"   initialise="false" value="false"
           minHoldMs="500" maxHoldMs="10000" defaultHoldMs="3000"/>
    <point name="tank_level" kind="HOLDING_REGISTER" address="100" initialise="false" value="0"/>
    <point name="fault"      kind="DISCRETE_INPUT"   address="10"/>
    <point name="flow_rate"  kind="INPUT_REGISTER"   address="30"/>
  </points>
</config>

The <modbus> block points the Bridge at the remote Modbus server: its host <ip>, TCP <port> (the Modbus standard is 502; the example uses 1502 because ports below 1024 need root on macOS/Linux), the unit / slave <id> to address, and the <pollIntervalMs> between reads. The optional <maxHoldMs> is the default hold cap for points that do not set their own maxHoldMs attribute (60 000 ms if absent) — see Pulse below. The <webserver> block sets the bind address for the HTTP server (0.0.0.0 = all interfaces) and the port it listens on. The optional <restapi> block maps application-facing ids onto points — see REST ids below.

Each <point> gives a human-friendly name (how REST clients and the web UI refer to it), a Modbus data table kind, and a zero-based protocol address. Valid kinds are COIL, DISCRETE_INPUT, HOLDING_REGISTER and INPUT_REGISTER. Only coils and holding registers are writable — discrete inputs and input registers are read-only in the Modbus data model (there is no client-write function code for them).

Startup initialisation (optional)

Add initialise="true" value="..." to a writable point to have the Bridge drive it to a known state at startup:

  • On the first successful connect after the Bridge starts, it reads the point's current value and writes value only if it differs (an already-correct point is left untouched). This runs once — a later link drop and reconnect does not re-apply it, so runtime REST/operator changes survive an outage.
  • If the Modbus server is unavailable at startup, initialisation simply waits and runs on the first connect once it becomes reachable.
  • value is true/false (or 1/0) for coils, and a 0..65535 integer for holding registers. initialise defaults to false.

Configuration is rejected at startup if initialise="true" is set on a read-only kind, or without a value, or with a value that does not match the kind.

Per-point pulse hold policy (optional, writable points only)

minHoldMs, maxHoldMs and defaultHoldMs control what a pulse request may ask this point to hold for:

  • minHoldMs / maxHoldMs are the enforced bounds — a pulse whose holdMs falls outside them is rejected with 400. Defaults: min 1, max = the global <maxHoldMs> (60 000 if that is absent too). A tight per-point range (e.g. 500..10000 on a relay coil) means a typo like 30000 for 3000 can't hold the relay closed for half a minute.
  • defaultHoldMs is the hold applied when a pulse request omits holdMs entirely, and is what the web UI pre-fills in the row's hold field (it is reported by the API for other clients to use the same way). Default: 3000 — sized for switching an electrical relay — clamped into the min/max range. Keeping callers' request bodies to just {"value": 1} leaves the relay timing centralised in the config file.
  • Config-load validation enforces 1 ≤ minHoldMs ≤ defaultHoldMs ≤ maxHoldMs and rejects the attributes on read-only kinds.

REST ids (application names for points)

The optional <restapi> section maps application-facing ids onto points, separating the caller's vocabulary from the physical wiring. Each <id> gives a name the REST API accepts and the point it resolves to; several ids may map to the same point:

<restapi>
  <id name="LANEA" point="PLC_Link1"/>
  <id name="LANEB" point="PLC_Link1"/>
  <id name="LANEC" point="PLC_Link1"/>
  <id name="LANED" point="PLC_Link1"/>
  <id name="LANEE" point="PLC_Link2"/>
</restapi>

This lets a calling application address the Bridge in its own terms — lane names, machine names, whatever its domain uses — while the mapping to real Modbus points stays in the Bridge's configuration. As an example, the Commander4j web Issue application (c4j_web_Issue) signals a wrong-lane error by pulsing a relay, using the production lane id as the point name — but several lanes may share one physical relay. Mapping all of those ids onto the single point (PLC_Link1 above) means the Bridge sees them as one entity, so pulses fired for different lanes in quick succession queue on that point's single pulse queue — each set→hold→reset cycle completes before the next starts, instead of several independent "points" at the same address interfering with each other's hold and reset. The same mechanism serves any application whose naming does not map one-to-one onto the physical wiring. Note the consequence: a queued pulse's HTTP response is delayed until its turn comes (up to queue-depth × hold time), so callers should use a generous timeout or call asynchronously.

  • Ids work everywhere a {name} is acceptedGET/PUT /api/points/{name} and POST /api/points/{name}/pulse — and single-point responses (and error messages) echo the name the caller used. The points list, SSE stream and web UI always show the point once, under its own name; the id→point map is reported in /api/status ("restIds") and shown under the point's name in the web UI. Log lines record both, e.g. Pulse accepted for PLC_Link1 (as LANEB), so the originating caller stays traceable even though the wire only sees the shared point.
  • caseSensitive (optional, default false) controls matching: by default an id matches any capitalisation (LANEA, LaneA, lanea). Point names themselves remain case-sensitive and stay addressable alongside the ids.
  • Validation at load: every id must reference an existing point; ids and point names share one namespace, so exact duplicates are rejected, and a case-insensitive id reserves its whole case class (nothing else may differ from it only by capitalisation). Bad configuration fails startup loudly, like every other config error.

Simulated points

Add simulate="true" to any point to detach it from the wire entirely — for keeping a calling application working, without error-handling changes, while the physical Modbus device is out of service:

<point name="pump_run" kind="COIL" address="0" simulate="true" value="false"/>
  • REST callers cannot tell the difference. Reads return the in-memory value (always "valid":true,"stale":false); writes and pulses succeed with the normal responses and are visible to subsequent reads; read-only kinds still reject writes with 400. The points payload deliberately carries no simulation marker, so the client↔bridge conversation is unchanged.
  • The poll thread never touches a simulated point, and a dead link (or <modbus enabled="false">) does not mark it stale or fail its writes — that is the whole purpose.
  • value seeds the simulated value at startup (true/false/1/0 for bit kinds, 0..65535 for registers; default 0). Values are held in memory only: a restart resets to the seed.
  • simulate cannot be combined with initialise (there is no wire to initialise), and any kind may be simulated — a simulated DISCRETE_INPUT simply reports its seed until changed at source.
  • For the operator (not the REST caller): /api/status reports the simulated point names in "simulatedPoints", and the web UI tags those rows simulated.

When the whole device is out of service, <modbus enabled="false"> (attribute on the <modbus> element; default true) stops the Bridge connecting at all — no reconnect attempts, no backoff noise in the log. Real points then behave exactly as during an outage (stale reads, 503 writes) while simulated points keep working. /api/status reports "modbusEnabled":false and the web UI banner says the link is disabled rather than disconnected.

Logging is configured in xml/config/log4j2.xml (rolling file at logs/modbusBridge.log, 10 MB cap, 5 generations). The Bridge's own classes log at debug; the Netty transport at warn.

REST API

Method Path Behaviour
GET /api/points All points with current values + connection status.
GET /api/points/{name} One point's current value. 404 if unknown. {name} is a point name or a restapi id (everywhere below too); the response echoes the name the caller used.
PUT /api/points/{name} {"value": N} Write a coil (0/1, or true/false) or holding register (0–65535). 400 for read-only kinds or out-of-range; 503 if the server is unreachable.
POST /api/points/{name}/pulse {"value": N [, "holdMs": N, "restValue": N]} Momentary output: set the point active, hold holdMs (default: the point's defaultHoldMs), then guarantee a reset to restValue (default 0). See below. 200 once energised; 503 if it couldn't be; 409 if too many pulses are already queued for the point; 400 for read-only kinds, out-of-range values, or holdMs outside the point's minHoldMs..maxHoldMs.
GET /api/status Connection health: connected, target, unit id, poll interval, max hold, last-poll time, point count, modbusEnabled, simulatedPoints (names), restIds (point → its restapi ids).
GET /api/licence Third-party licence list (from lib/license/LicenseInfo.xml).
GET /api/log/tail ?lines=N Recent log lines (default 200, max 5000).
GET /events Server-Sent Events stream of the full points payload, pushed on every change.
GET /events/log Server-Sent Events stream of newly-appended log lines.

A write is self-correcting, not optimistic: the Bridge writes the value, then immediately reads the point back from the server, so the response and cache reflect the server's actual state. When the link is down, reads return the last snapshot flagged "valid":false,"stale":true and writes return 503. Simulated points are exempt from all of this: their reads, writes and pulses always succeed against the in-memory value, link or no link, and their payloads are indistinguishable from a live point's.

Examples:

curl http://localhost:8080/api/points
curl -X PUT -H 'Content-Type: application/json' -d '{"value":1}'    http://localhost:8080/api/points/pump_run
curl -X PUT -H 'Content-Type: application/json' -d '{"value":4242}' http://localhost:8080/api/points/tank_level

Pulse (momentary output)

POST /api/points/{name}/pulse sets a writable point active, holds it, then guarantees it returns to a rest value — with the timing owned by the Bridge, not the caller. It exists to drive a PLC relay safely: a dropped web session, a client crash, or a link blip during the hold can never leave the relay energised.

The following energises pump_run for 3 seconds, then resets it to 0:

curl -X POST -H 'Content-Type: application/json' -d '{"value":1,"holdMs":3000,"restValue":0}' http://localhost:8080/api/points/pump_run/pulse
  • The response is a real confirmation. 200 means the relay was energised and its reset is scheduled; 503 means nothing was energised (link down) and nothing is owed. The set runs on the request thread; the hold and reset run in the background.
  • The reset is guaranteed across a dropped caller session, a link drop during the hold (it retries as the Bridge reconnects), and a graceful service stop (the reset is flushed before the Modbus link closes). It is not guaranteed across a hard kill or power loss — for that, configure a PLC-side hardware watchdog that de-energises a coil that stops being refreshed.
  • holdMs is optional — when omitted, the point's configured defaultHoldMs applies, so a caller can send just {"value": 1} and leave the relay timing to the Bridge's configuration. When supplied, it must be within the point's minHoldMs..maxHoldMs (see the config section; defaults 1..60000) so a typo can't hold a relay for hours — set a tight per-point range on real relay coils. restValue is optional (default 0). The point's bounds and its defaultHoldMs are reported in every points payload for clients to discover.
  • Concurrency: pulses on the same point run one complete set→hold→reset cycle at a time, so the relay returns to rest between them; a caller that outruns the queue gets 409. Pulses on different points are independent. Note that a queued pulse's HTTP response is delayed until its turn to fire (up to holdMs × its position in the queue), so a client's request timeout must exceed the worst-case wait, or it may abandon a pulse that still fires server-side.

Web UI

Open http://<host>:8080/ in a browser:

  • Points (index.html) — a live table of every configured point, updated by push (SSE) with no refresh needed. Writable points have an inline editor; type a value and click Set (or press Enter) to issue the PUT. Next to it, a Pulse button with a hold-time field (pre-filled from the point's defaultHoldMs, bounded by its minHoldMs/maxHoldMs) sets the value, holds it for that time, then auto-resets to 0 — the timing runs server-side (see Pulse), so closing the page mid-hold cannot leave the point energised. You can watch the value go active and drop back live in the table. Stale values (link down) are shown in red. A manual Refresh button is kept as a fallback.
  • Log (log.html) — the recent log backlog followed live as new lines are written, with an auto-scroll toggle. WARN / ERROR lines are colour-coded.
  • Licences (licence.html) — the third-party libraries and their licences rendered as a table (the underlying /api/licence endpoint returns the same data as raw JSON).

Modbus Behaviour

  • Transport: Modbus/TCP only (no serial / RTU, no TLS), reusing the ModbusClient's Modbus engine.
  • The poll thread reads every configured point once per pollIntervalMs. A read failure tears the connection down, flags all points stale, and the Bridge auto-reconnects with exponential backoff (1 → 2 → 4 → 8 … up to 30 s), resetting on a successful connect.

Troubleshooting

  • Address already in use on start — another process holds the web port (default 8080). Change <webserver><port> or free the port.
  • All points show stale / /api/status says not connected — the Modbus server is unreachable. Check <modbus><ip>/<port>, that the server is running, and that both ends use the same unit id (the server answers only its configured unit). The log shows the backoff retries.
  • Writes return 503 — the link is down at that moment; the Bridge is between reconnect attempts. Reads still return the last-known (stale) values.
  • All points stale but no reconnect attempts in the log — the Modbus link may be switched off in the configuration: check for <modbus enabled="false"> (/api/status reports "modbusEnabled":false and the web UI banner shows disabled rather than disconnected).
  • PUT returns 400 "read-only" — the named point is a DISCRETE_INPUT or INPUT_REGISTER; those cannot be written by a Modbus client.
  • No log lines / empty log tail page — confirm -Dlog4j2.configurationFile=xml/config/log4j2.xml is set (the start scripts do this) and the process working directory is the project root, so logs/ is created in the right place.

See Also

  • ModbusClient — the interactive desktop Modbus client; the Bridge reuses its Modbus engine
  • ModbusServer — the server-side companion tool, useful as a bench target for the Bridge
  • AutoLab4j — uses the same Modbus stack to monitor a coil and trigger a labeller on a change of state
  • SocketTest — a raw TCP/IP testing utility