Barionet

Modbus TCP App

Version

Changelog

Date

Download

1.0.0

Initial Release

modbus-io-server-v1.0.0.zip

Application Version: 1.0.0
Compatible Firmware: Flexa Firmware min. v2.3.4 (click to download)
Device Compatibility: Barionet M44, UX8 Extension

IMPORTANT NOTICE

This implementation supports up to 4 UX8 extension modules, with corresponding addresses reserved in the IO addressing table. However, at the time of this documentation, only configurations with up to 1 UX8 extension have been fully tested and validated. While the system is designed to handle 4 extensions, configurations using 2 or more UX8 modules have not yet undergone comprehensive testing.

Overview

A reference Flexa App that turns a Barix Barionet M44 into a Modbus TCP server
(slave). A PLC, SCADA system or HMI acting as Modbus master can read every input the
Barionet has and drive its relays, subject to a write policy the operator configures in
the device web UI.

How it fits together

   PLC / SCADA  ──Modbus TCP──▶  MODBUS_IO_SERVER  ──D-Bus──▶  io-mapping  ──▶  hardware
    (master)          :502          (this app)                  (firmware)      relays, DIs,
                                                                                analogs, 1-Wire

The M44 firmware has no Modbus service of its ownio-mapping is the abstraction
layer that gives every physical and virtual I/O a numeric address. This app maps those
addresses onto the four Modbus register spaces and serves them over TCP.

Two implementation choices are worth knowing before you fork this app:

  • Reads are cached, writes are not. One iomapping.read_value() costs ~3.6 ms on an
    M44; a batched read_values() costs ~0.7 ms per address. A master polling 40 registers
    every 100 ms cannot afford a D-Bus round trip per register, so a background thread
    refreshes an I/O cache and Modbus reads are answered from it. The Poll interval
    setting is therefore the worst-case age of any value. Writes go straight to
    io-mapping and then update the cache, so a read-back immediately after a write
    returns the new value.

  • The write policy lives in the datastore. PolicySlaveContext.validate() is the
    only pymodbus hook that sees both the target address and the function code, so it can
    allow a read and reject a write on the same address. Denied writes get exception code
    0x02 (ILLEGAL DATA ADDRESS).

Register map

Addressing is zero-based (zero_mode=True). Tools that use the classic 1-based
0xxxx/1xxxx/3xxxx/4xxxx notation show coil 0 as 00001, discrete input 0 as 10001,
input register 0 as 30001 and holding register 0 as 40001.

Addresses not listed are intentional gaps and answer with ILLEGAL DATA ADDRESS —
that is correct Modbus behaviour, not a fault.

Coils — FC01 read, FC05/FC15 write (bits, read/write)

Coil

IO address

Group

Description

0–3

1–4

relay

Relays 1–4 (onboard)

4–11

11–18

relay

UX8 #1 relays 1–8

12–19

19–26

relay

UX8 #2 relays 1–8

20–27

27–34

relay

UX8 #3 relays 1–8

28–35

35–42

relay

UX8 #4 relays 1–8

40–43

301–304

pullup

Digital input pull-ups 1–4

50–79

43–72

virtual

Virtual bits 1–30 (scratch, no hardware effect)

90

1207

system

USB ports enable

Discrete inputs — FC02 (bits, read-only)

Discrete input

IO address

Description

0–3

201–204

Digital inputs 1–4

4–11

211–218

UX8 #1 digital inputs 1–8

12–19

219–226

UX8 #2 digital inputs 1–8

20–27

227–234

UX8 #3 digital inputs 1–8

28–35

235–242

UX8 #4 digital inputs 1–8

40

209

RS-232 CTS input

50–53

60007–60010

UX8 module #1–#4 detected

Input registers — FC04 (16 bit, read-only)

Input register

IO address

Description

0–3

501–504

Analog inputs 1–4, mV

4–11

511–518

UX8 #1 analog inputs 1–8, mV

12–19

519–526

UX8 #2 analog inputs 1–8, mV

20–27

527–534

UX8 #3 analog inputs 1–8, mV

28–35

535–542

UX8 #4 analog inputs 1–8, mV

40–89

601–650

Temperature sensors 1–50, m°C (divide by 1000)

100–107

401–404

Digital input counters 1–4, 32 bit, high word first

200

1201

Supply current, mA

201

1202

Supply voltage, mV

202

1203

CPU temperature, m°C

203–204

1204

Device uptime, seconds, 32 bit, high word first

205

1205

Hardware type (82 = Barionet M44)

206

1206

Firmware version (0x0234 = v2.3.4)

207

60002

Relay count including UX8

208

60004

Digital input count including UX8

209

60006

Analog input count including UX8

210

app

Seconds since the last Modbus request

211

app

Watchdog state: 0 = armed/idle, 1 = tripped

212

app

Currently connected Modbus masters

Holding registers — FC03 read, FC06/FC16 write (16 bit, read/write)

Holding register

IO address

Group

Description

10–39

543–572

virtual

Virtual 16-bit registers 1–30

Value conventions

  • Values wider than 16 bits (counters, uptime) occupy two registers, high word first.

  • Anything that does not fit 16 bits — including negative temperatures in m°C — is
    carried as 16-bit two's complement.

  • Registers for hardware that is absent (an unfitted UX8, an uninstalled temperature
    sensor) stay readable and return 0.

Installation Process

Installing the application requires uploading the package zipped file directly to your Barionet device through its web interface. This process is straightforward but requires careful attention to ensure proper installation.

NOTE: If your Barionet M44 is already running an application you must RESET TO DEFAULTS before proceeding and installing a new one.

Step 1: Access the Device Web Interface

  1. Open your web browser and navigate to your Barionet device's IP address

  2. Enter your administrator username and password when prompted

  3. Wait for the main interface to load completely before proceeding

    image-20250729-134518.png
    Barionet M44 User Interface

Step 2: Upload the Application Package

  1. Navigate to the HOME tab in the main menu

  2. Locate the "Upload" button next to “Install Package” within the page

  3. Click the file selection button and browse to the “<package>.zip” which was downloaded from this page

  4. Choose an appropriate version number for tracking purposes (this can be any number you prefer for identification)

  5. Click the UPLOAD button to begin the installation process

Step 3: Device Reboot and Verification

After the upload completes, your Barionet device will automatically initiate a reboot sequence. This reboot is necessary to properly integrate the new application into the system. The reboot process typically takes 30..40s

  1. Wait for the device to complete its reboot cycle

  2. Reconnect to the web interface using the same IP address and credentials

  3. Navigate back to the HOME tab

  4. Verify that the application status appears with a status of "running"

    image-20260817-091623.png


Configuration Overview

Once successfully installed, the configuration interface becomes available through the Application tab in your device's web interface. This dedicated configuration page provides access to all settings necessary to establish communication between the device and the server.

The configuration system is designed to be intuitive, each setting includes helpful descriptions and validation to prevent configuration errors that could disrupt communication.

image-20260817-091711.png

Configuration (web UI → Modbus TCP tab)

Section

Setting

Default

Notes

Modbus TCP Server

Enable

on

Turns the slave off without uninstalling


Listen port

502



Unit / slave ID

1

Requests for other unit IDs get no reply


Max simultaneous masters

4

Excess connections are closed; 0 = unlimited

Access Control

Read-only mode

off

Master switch — rejects every write function


Allow relay writes

on

Coils 0–35


Allow pull-up writes

off

Coils 40–43


Allow virtual IO writes

on

Coils 50–79, holding 10–39


Allow system writes

off

Coil 90, holding 0–3


Master IP whitelist

(empty)

Comma-separated; empty = any master

Communication Watchdog

Enable

off



Communication timeout

30 s

Modbus silence before the safe state is applied


Startup grace period

60 s

Watchdog stays inactive for this long after start


Safe state action

Open all relays

Open all / close all / fixed pattern / report only


Relay 1–4 safe pattern

0,0,0,0

Only for the pattern action


Include UX8 relays

off

Only for open-all / close-all

IO Polling

Poll interval

250 ms

Worst-case age of a value read over Modbus


Temperature sensors installed

4

Only this many 1-Wire sensors are polled


Poll UX8 extension IO

Auto

Auto = only modules detected at startup

Logging

Log level

INFO



Enable Syslog / Syslog Server

off

The only debugging channel on a sealed device

Submitting the form writes config.json and restarts the app — it always starts from
a clean state and never relies on anything held in memory beforehand.

Modbus TCP Server

Enable Modbus TCP serverserver.enable, boolean, default on
Turns the Modbus slave on or off without uninstalling the package. When off, the app
starts, logs server disabled in configuration -- idling and then sits idle rather than
exiting — a Flexa app that exits is restarted by the platform watchdog, which would loop
forever. Nothing binds the port and the IO poller does not run, so the app costs
essentially nothing while disabled. Use this to take the device off a live Modbus network
without losing your configuration.

Listen portserver.listen_port, 1–65535, default 502
The TCP port the server binds to, on all interfaces. 502 is the registered Modbus port
and what masters assume by default. Change it only if something else on the device already
holds 502, or if you deliberately want the service on a non-standard port. Ports below
1024 need root, which Flexa apps have. If the bind fails the app logs
cannot bind 0.0.0.0:<port> and stops — check the log if the service seems absent.

Unit / slave IDserver.unit_id, 1–247, default 1
The Modbus unit identifier this device answers to. Requests carrying any other unit ID get
no reply at all rather than an exception, which is the friendlier behaviour when
several devices share a gateway or a serial bridge downstream. If your master reports
timeouts on every register, a unit ID mismatch is the first thing to check — the symptom
is silence, not an error code.

Maximum simultaneous mastersserver.max_connections, 0–32, default 4
How many Modbus TCP connections may be open at once. A connection beyond the limit is
accepted by the TCP stack and then closed immediately, which the client sees as
"connection reset by peer". Set 0 for no limit. The limit protects the device from a
master that reconnects without closing its old sockets — a common failure mode that
otherwise accumulates connections until memory runs out. Input register 212 reports how
many masters are currently connected, so you can see how close you are to the limit.

Access Control

This section is the "control decisions" surface: it decides what a Modbus master is
allowed to change. Denied writes are answered with exception code 0x02 (ILLEGAL DATA
ADDRESS) and logged at WARNING level with the function code and address, so a rejected
write is visible in the log rather than failing silently. Reads are never restricted
an address that is blocked for writing stays fully readable.

Read-only modeaccess.read_only, boolean, default off
Master switch. When on, every write function (FC05, FC06, FC15, FC16) is rejected
regardless of the four group switches below, and the startup log records
access policy: read-only (all writes rejected). This is the setting to use when the
Barionet is purely a data source for a SCADA system and must never be actuated from the
network — commissioning a new master, for instance, without any risk of it driving your
plant.

Allow relay writesaccess.allow_relay_writes, boolean, default on
Governs coils 0–35: the four onboard relays and all UX8 extension relays. This is the one
group that is on by default, because driving relays is the usual reason to install this
app. Turning it off leaves the relay states readable while making them untouchable from
the network.

Allow pull-up writesaccess.allow_pullup_writes, boolean, default off
Governs coils 40–43, the pull-up resistors on digital inputs 1–4. Off by default because
changing a pull-up alters how its input reads — a master that writes these can silently
change the meaning of your input data, which is rarely what you want at runtime. Enable it
only if you genuinely need to reconfigure input biasing remotely.

Allow virtual IO writesaccess.allow_virtual_writes, boolean, default on
Governs coils 50–79 and holding registers 10–39: scratch bits and registers with no
hardware effect whatsoever. They are useful as a handshake area between the master and
anything else running on the device — mode flags, setpoints, a heartbeat the master writes
and another app reads. Safe to leave on, since nothing physical can move as a result.

Allow system writesaccess.allow_system_writes, boolean, default off
Governs coil 90 (USB ports enable) and holding registers 0–3 (user LED colour and
brightness). Off by default because these change device-level behaviour rather than
process IO. The LEDs are genuinely useful as a remote status indicator — driving LED
colour from a PLC to show plant state is a neat trick — but enable this deliberately.

Master IP whitelistaccess.ip_whitelist, string, default empty
Comma-separated list of IP addresses permitted to connect, e.g.
192.168.1.10, 192.168.1.11. Leave empty to accept any master. A connection from an
address not on the list is closed immediately on connect and logged as
connection from <ip> rejected: not in the IP whitelist; the client sees a connection
reset. This is a coarse filter, not authentication — Modbus TCP has no authentication at
all, and an address can be spoofed — but it stops accidental cross-connection on a shared
plant network, which is the realistic risk. Exact addresses only; ranges and CIDR are not
supported.

Communication Watchdog

The only place the app makes a decision on its own. Everything in this section is hidden
in the UI until Enable watchdog is ticked.

Enable watchdogwatchdog.enable, boolean, default off
When on, a background thread checks once per second how long it has been since the last
Modbus request of any kind — read or write, from any master. If that exceeds the timeout,
the app drives the relays to the configured safe state, logs at ERROR level, and sets
input register 211 to 1.

The behaviour is deliberately one-shot: the safe state is applied once, and the app
does not repeat it or restore the previous relay states when the master returns. The
master owns the relays again the moment it is back. Input register 211 returns to 0 within
a second of traffic resuming, and the log records watchdog re-armed.

Communication timeoutwatchdog.timeout_s, 1–3600 s, default 30
Seconds of Modbus silence before the safe state is applied. Set it to several times your
master's normal poll period — a watchdog that trips on ordinary jitter is worse than none.
If the master polls every second, 10–30 s is sensible; if it polls once a minute, this
must be well above 60.

Startup grace periodwatchdog.startup_grace_s, 0–3600 s, default 60
Seconds after app start during which the watchdog stays inactive. This exists because the
app comes up before the master reconnects — after a device reboot, or after any config
save. Without a grace period every restart would trip the watchdog and drive your relays.
Make it comfortably longer than the worst-case time for your master to notice the device
and reconnect.

Safe state actionwatchdog.action, default Open all relays
What to do when the watchdog trips:

  • Open all relays (all_off) — de-energise everything. The right default for most
    plant: loss of control means stop.

  • Close all relays (all_on) — energise everything. For fail-closed designs, e.g. a
    valve that must be driven shut on loss of comms.

  • Apply a fixed pattern to relays 1–4 (pattern) — a per-relay safe state, for when
    some outputs should drop and others should hold or engage.

  • Do nothing (report only) (none) — no relays are touched; the trip is still logged
    and still visible in input register 211. Useful for observing how often comms actually
    drop before committing to an action that moves hardware.

Relay 1–4 safe patternwatchdog.relay_pattern, default 0,0,0,0
Visible only when the action is "pattern". Four comma-separated values, 0 = open,
1 = closed, applied to onboard relays 1–4 in order. UX8 relays are not covered by the
pattern action — use open-all or close-all if you need them included.

Include UX8 extension relayswatchdog.include_ux8, boolean, default off
Visible only for the open-all and close-all actions. Extends the safe state to the
relays of every UX8 module detected at startup, in addition to the four onboard relays.
Off by default so that adding an extension never silently widens the blast radius of a
watchdog trip.

IO Polling

These settings trade freshness against device load. Modbus reads are answered from a
cache that a background thread refreshes; nothing here affects writes, which always go
straight to the hardware.

Poll intervalpolling.poll_interval_ms, 20–10000 ms, default 250
How often the cache is refreshed, and therefore the worst-case age of any value a
master reads. Reading one IO address costs roughly 0.7 ms in a batch, so a full sweep of
the default map takes on the order of 100 ms. If you set an interval shorter than one
sweep, the poller simply runs continuously — it never overlaps sweeps or queues up — and
logs a DEBUG line noting the overrun. Lower it for fast digital inputs; raise it to 1000 ms
or more if you are only trending slow analog values and want the CPU back.

Temperature sensors installedpolling.temp_sensor_count, 0–50, default 4
How many 1-Wire sensors to poll, mapped onto input registers 40 upward. The map always
exposes all 50 slots; this only controls how many are actually read from the hardware.
Unpolled slots stay readable and return 0. Since each sensor adds to every sweep, setting
this to the number you actually have — or 0 if none — is the cheapest way to shorten the
poll cycle.

Poll UX8 extension IOpolling.poll_ux8, default Auto

  • Auto — poll only the UX8 modules detected at startup. Right for almost everyone.

  • Always poll all four modules — poll all 96 UX8 addresses whether or not the modules
    are present. Absent modules read 0. Only useful if detection is unreliable on your unit.

  • Never poll UX8 IO — skip UX8 entirely and shorten the sweep, even if modules are
    fitted. Their registers then read 0.

Detection happens at startup only, so restart the app (or save the config form) after
adding or removing an extension module.

Logging

There is no SSH on a sealed production device, so syslog is the only way to see what the
app is doing in the field.

Log levellogging.log_level, default INFO

  • Error — failures only: bind failures, io-mapping read/write errors, watchdog trips.

  • Warning — adds denied writes and rejected connections. A good production setting if
    INFO is too chatty: you still see every security-relevant event.

  • Info — adds startup detail (detected hardware, the full register map, the active
    access policy) and every master connect/disconnect. The default, and what you want when
    commissioning.

  • Debug — adds poll-overrun notices. pymodbus's own logging is deliberately capped at
    WARNING regardless of this setting, because it logs every frame and would flood a
    syslog server.

Enable Sysloglogging.enable_syslog, boolean, default off
Sends log output to a remote syslog server, facility LOCAL0, in addition to stdout
(which Flexa captures). If the handler cannot be created — bad address, DNS failure — the
app logs the problem locally and carries on rather than refusing to start.

Syslog Serverlogging.syslog_address, string, default empty
The server as IP:port, e.g. 192.168.1.10:514. The port may be omitted, in which case
514 is used. Ignored unless Enable Syslog is ticked.

Watchdog semantics

If no Modbus request of any kind arrives for Communication timeout seconds, the app
drives the relays to the configured safe state, logs at ERROR level, and sets input
register 211 to 1. It is one-shot: it does not repeat, and it does not restore the
previous relay states when the master returns — the master owns them again as soon as it
is back. Input register 211 returns to 0 within a second of traffic resuming.

The startup grace period exists because the app comes up before the master reconnects;
without it, every restart would trip the watchdog.

Quick check with pymodbus

Run the below code snippet on a machine where pymodbus is available:

Python
#!/usr/bin/env python3
"""Test / diagnostic client for MODBUS_IO_SERVER on a Barionet M44.

Works with both pymodbus API generations:
  * <= 3.7  -> read_coils(addr, count, slave=1)
  * >= 3.8  -> read_coils(addr, count=count, device_id=1)

Usage: python test_modbus.py [host] [port] [unit_id] [relay_hold_seconds]

Sections:
  1. Snapshot     -- reads every mapped block once.
  2. Input test   -- interactive: asks you to close a contact and prints what
                     comes back over Modbus, sample by sample.
  3. Relay test   -- energises relays 1-4 for the hold period, then releases.
                     Asks for confirmation first.

WARNING: the relay test physically actuates relays 1-4.
"""

import inspect
import sys
import time

import pymodbus
from pymodbus.client import ModbusTcpClient

HOST = sys.argv[1] if len(sys.argv) > 1 else "192.168.2.20"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 502
UNIT = int(sys.argv[3]) if len(sys.argv) > 3 else 1
HOLD_S = int(sys.argv[4]) if len(sys.argv) > 4 else 5

# pymodbus renamed the unit-id keyword from "slave" to "device_id" in 3.8,
# and made "count" keyword-only at the same time.
_params = inspect.signature(ModbusTcpClient.read_coils).parameters
UNIT_KW = "device_id" if "device_id" in _params else "slave"
UNIT_ARG = {UNIT_KW: UNIT}

INTERACTIVE = sys.stdin is not None and sys.stdin.isatty()


def read(fn, address, count):
    return fn(address, count=count, **UNIT_ARG)


def show(tag, response, attr, count=None):
    if response.isError():
        print("%-16s ERROR: %s" % (tag, response))
        return None
    values = getattr(response, attr)
    # Modbus packs bit responses into whole bytes, so asking for 4 coils returns
    # 8 bits -- the last 4 are protocol padding. Trim them off.
    if count is not None:
        values = values[:count]
    print("%-16s %s" % (tag, values))
    return values


def ask(prompt):
    """Wait for Enter. Returns False if the user asked to skip."""
    try:
        answer = input(prompt + " [Enter = go, s = skip] ").strip().lower()
    except (EOFError, KeyboardInterrupt):
        print()
        return False
    return answer != "s"


# ==========================================================================
# 1. SNAPSHOT
# ==========================================================================
def snapshot(client):
    print("--- snapshot ---")
    show("relays 1-4:", read(client.read_coils, 0, 4), "bits", 4)
    show("digital in 1-4:", read(client.read_discrete_inputs, 0, 4), "bits", 4)
    show("pull-ups 1-4:", read(client.read_coils, 40, 4), "bits", 4)
    show("analog mV:", read(client.read_input_registers, 0, 4), "registers")

    system = show("supply/CPU:", read(client.read_input_registers, 200, 3), "registers")
    if system:
        print("%-16s %d mA, %.2f V, CPU %.1f C"
              % ("decoded:", system[0], system[1] / 1000.0, system[2] / 1000.0))

    app = show("app regs:", read(client.read_input_registers, 210, 3), "registers")
    if app:
        print("%-16s idle %d s, watchdog %s, %d master(s) connected"
              % ("decoded:", app[0], "TRIPPED" if app[1] else "ok", app[2]))


# ==========================================================================
# 2. DIGITAL INPUT TEST
# ==========================================================================
#
# Prints the raw Modbus payload as well as the decoded value, because the two
# answer different questions: the raw byte proves what actually came back on
# the wire, the decoded value proves how this script interpreted it.


def read_inputs(client):
    """Return (bits[0:8], raw_byte, counters[0:4]) or None on error."""
    di = read(client.read_discrete_inputs, 0, 4)
    ct = read(client.read_input_registers, 100, 8)
    if di.isError() or ct.isError():
        print("  read error: %s / %s" % (di, ct))
        return None
    bits = [int(b) for b in di.bits[:8]]
    raw = sum(bit << index for index, bit in enumerate(bits))
    regs = ct.registers
    counters = [(regs[i * 2] << 16) | regs[i * 2 + 1] for i in range(4)]
    return bits, raw, counters


def sample(client, seconds=2.0, interval=0.2):
    """Poll for a couple of seconds and print every reading."""
    started = time.time()
    last = None
    while time.time() - started < seconds:
        result = read_inputs(client)
        if result is None:
            return None
        bits, raw, counters = result
        print("   t+%4.1fs  FC02 bits=%s raw=0x%02X | DI1-4=%s | counters=%s"
              % (time.time() - started, bits, raw, bits[:4], counters))
        last = result
        time.sleep(interval)
    return last


def input_test(client):
    print("\n--- digital input test ---")
    if not INTERACTIVE:
        print("stdin is not a terminal -- skipping the interactive part.")
        sample(client, seconds=1.0, interval=0.5)
        return

    print("Reads discrete inputs 0-3 (FC02, io-mapping 201-204) and the edge")
    print("counters (FC04 registers 100-107, io-mapping 401-404).")
    print("The counters are maintained by the firmware, so they move even if the")
    print("input mapping in the app were wrong -- that is what makes them useful.\n")

    try:
        answer = input("Which digital input are you testing? [1-4, default 2] ").strip()
    except (EOFError, KeyboardInterrupt):
        print()
        return
    try:
        channel = int(answer) if answer else 2
    except ValueError:
        channel = 2
    if not 1 <= channel <= 4:
        channel = 2
    index = channel - 1
    print("testing digital input %d -> discrete input %d, io-mapping address %d\n"
          % (channel, index, 200 + channel))

    print("Leave the contact OPEN for the baseline.")
    if not ask("Ready?"):
        return
    baseline = sample(client)
    if baseline is None:
        return

    print("\nNow CLOSE the contact on digital input %d and keep it closed." % channel)
    if not ask("Closed?"):
        return
    closed = sample(client)
    if closed is None:
        return

    print("\nNow OPEN the contact again.")
    if not ask("Opened?"):
        return
    reopened = sample(client)
    if reopened is None:
        return

    # --- verdict ----------------------------------------------------------
    print("\n--- result for digital input %d ---" % channel)
    open_state, closed_state = baseline[0][index], closed[0][index]
    open_count, closed_count = baseline[2][index], reopened[2][index]

    print("state  open -> closed -> open : %d -> %d -> %d"
          % (open_state, closed_state, reopened[0][index]))
    print("edge counter %d -> %d (%+d)" % (open_count, closed_count, closed_count - open_count))

    if open_state != closed_state:
        print("\nOK: the input state follows the contact.")
        print("    Closed contact reads %d, open contact reads %d." % (closed_state, open_state))
    elif closed_count != open_count:
        print("\nThe edge counter moved but the level did not.")
        print("    The firmware saw the transition, so the wiring is fine, but the")
        print("    contact did not stay closed long enough to survive one poll")
        print("    cycle -- or it is bouncing. Lower the poll interval in the")
        print("    Modbus TCP tab, or hold the contact closed for longer.")
    else:
        print("\nNo change seen: neither the input level nor the edge counter moved.")
        print("    The firmware never registered a transition, so this is upstream")
        print("    of the Modbus app. Things to check:")
        print("      * wiring -- the M44 inputs expect a dry contact to GND")
        print("      * pull-up for this input (coil %d) -- currently %s"
              % (40 + index, "on" if baseline_pullup(client, index) else "OFF"))
        print("      * the firmware's own IO dashboard at http://%s -- if it also" % HOST)
        print("        shows the input as open, the app is not involved at all")


def baseline_pullup(client, index):
    response = read(client.read_coils, 40, 4)
    if response.isError():
        return None
    return bool(response.bits[index])


# ==========================================================================
# 3. RELAY TEST
# ==========================================================================


def relay_test(client):
    print("\n--- relay test ---")
    print("This physically switches relays 1-4 on for %d s, then off." % HOLD_S)
    if INTERACTIVE and not ask("Energise relays 1-4?"):
        print("skipped.")
        return

    # FC15 (write multiple coils) switches all four in a single request, so they
    # actuate together rather than one at a time.
    result = client.write_coils(0, [True] * 4, **UNIT_ARG)
    if result.isError():
        print("write refused: %s  (check Access Control in the Modbus TCP tab)" % result)
        return

    show("relays 1-4 on:", read(client.read_coils, 0, 4), "bits", 4)
    try:
        for remaining in range(HOLD_S, 0, -1):
            print("  holding... %d s " % remaining, end="\r", flush=True)
            time.sleep(1)
        print(" " * 24, end="\r")
    finally:
        # Always release the relays, even on Ctrl+C, so the bench unit is never
        # left energised by an aborted test.
        client.write_coils(0, [False] * 4, **UNIT_ARG)
    show("relays 1-4 off:", read(client.read_coils, 0, 4), "bits", 4)


# ==========================================================================


def main():
    print("pymodbus %s (using '%s=' keyword) -> %s:%d unit %d\n"
          % (pymodbus.__version__, UNIT_KW, HOST, PORT, UNIT))

    client = ModbusTcpClient(HOST, port=PORT, timeout=3)
    if not client.connect():
        sys.exit("cannot connect to %s:%d" % (HOST, PORT))
    try:
        snapshot(client)
        input_test(client)
        relay_test(client)
    finally:
        client.close()


if __name__ == "__main__":
    main()

Verified behaviour

Tested against the live device with a pymodbus master:

  • Reads return live values — analog inputs ~12.7 V, supply 23.2 V, CPU 54.5 °C, uptime,
    hardware type 82, firmware 0x0234, UX8 #1 detected.

  • FC05 and FC15 drive the physical relays; FC06 and FC16 write holding registers;
    read-back after a write is immediate.

  • Unmapped addresses answer ILLEGAL DATA ADDRESS.

  • A write to a disallowed group answers ILLEGAL DATA ADDRESS while the same address
    stays readable; read-only mode rejects every write function.

  • A master outside the IP whitelist, and any connection past the limit, is closed on
    connect.

  • Requests for an unhosted unit ID get no reply.

  • The watchdog opens the relays after the timeout, sets input register 211, and re-arms
    when traffic resumes.

  • SIGTERM stops the poll thread, the watchdog and the server, and releases the port.

Extending it

  • Change the map — edit COIL_MAP, DISCRETE_MAP, INPUT_REG_MAP, HOLDING_MAP in
    main.py. They drive the map, the write policy and the startup log together, so there
    is exactly one place to change.

  • New write group — add a group name to a map table, to WritePolicy.allowed, and a
    checkbox to the Access Control section of config/sdf.json.

  • Block an address permanently — add its io-mapping address to BLOCKED_IO_ADDRESSES.

  • Modbus RTU over RS-485 — the M44 has a serial port and pyserial is installed;
    swap ModbusTcpServer for ModbusSerialServer, keeping the same data blocks.

  • Local control logic — the app deliberately makes no decisions beyond the watchdog.
    If you need threshold or edge-triggered rules, iomapping.enable_notifications()
    (addresses 1–400) gives you callbacks without polling.

Software Disclaimer

IMPORTANT:

This software application ("Software") is provided "AS IS" without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the author, developer, or distributor be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software.

Use at Your Own Risk: The user assumes all responsibility and risk for the use of this Software. The author makes no representations or warranties regarding the accuracy, reliability, completeness, or timeliness of the Software or its suitability for any particular purpose.

No Support Obligation: The provision of this Software does not create any obligation to provide technical support, maintenance, updates, enhancements, or modifications. The Software is provided for educational and reference purposes only.

Third-Party Dependencies: This Software may utilize third-party libraries and components. The user is responsible for ensuring compliance with all applicable licenses and terms of use for such dependencies.

Network and Security: The user is solely responsible for implementing appropriate security measures and network configurations. The author is not responsible for any security vulnerabilities, data breaches, or network disruptions that may result from the use of this Software.

Compliance: Users are responsible for ensuring that their use of this Software complies with all applicable laws, regulations, and organizational policies in their jurisdiction.

Limitation of Liability: Under no circumstances shall the total liability of the author exceed zero dollars ($0.00) for any damages arising out of or related to the use of this Software.

By using this Software, you acknowledge that you have read, understood, and agree to be bound by the terms of this disclaimer.