> ## Documentation Index
> Fetch the complete documentation index at: https://docs.coreflux.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Modbus TCP Server Route

> Expose MQTT topics as Modbus TCP registers and coils so SCADA, HMIs, and Modbus masters connect to your Coreflux broker

## Modbus TCP Server

> [!NOTE]
>
> This page documents the **server** route (`MODBUS_SERVER`)—the broker **hosts** a Modbus TCP server and exposes MQTT topics as register and coil addresses. External masters connect to the broker over TCP. To connect the broker **to** a remote Modbus device instead, see [Modbus TCP Client](./modbus-tcp-client).

The `MODBUS_SERVER` route makes your broker act as a **Modbus TCP server (slave)**. External masters—SCADA, HMIs, historians, PLCs—connect **to the broker** and read or write a declared register/coil address space that bridges bidirectionally to MQTT topics.

Use `MODBUS_TCP` when you need to **poll a device**. Use `MODBUS_SERVER` when OT systems need to **read and write** values that live on your MQTT bus—no separate gateway required.

> [!TIP]
>
> **Like a shop counter with a price list.** MQTT updates what's on the shelf (register values), and customers (Modbus masters) read the tags—or, for writable addresses, change items that publish back to MQTT.

## When to Use This Route

* **SCADA / HMI integration** — Let plant-floor tools poll broker data over standard Modbus without custom drivers.
* **OT/IT bridge** — Expose IoT sensor streams to masters that only speak Modbus.
* **Bidirectional control** — Writable tags let masters adjust setpoints; the broker publishes the new value to MQTT on a cadence you choose.
* **Client-only field devices** — Integrate master-only equipment into MQTT with no middleware between the broker and the bus.

***

The sections below cover data flow, configuration, persistence, and production patterns.

## Quick Start

**Expose sensor values**

Map an MQTT topic to a holding register masters can poll:

```lot wrap  theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE TempServer WITH TYPE MODBUS_SERVER
    ADD MAPPING Sensors
        ADD TAG Temperature
            WITH ADDRESS "100"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "FLOAT"
            WITH SOURCE_TOPIC "plant/line1/temperature"
```

Publish `23.5` to `plant/line1/temperature` → registers 100–101 hold the FLOAT; a master reading them gets `23.5`.

**Writable setpoint**

Allow a master to write a register; the broker publishes the value to MQTT within one `WITH EVERY` interval:

```lot wrap  theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE SetpointServer WITH TYPE MODBUS_SERVER
    ADD MAPPING Control
        WITH EVERY 500 MILLISECONDS
        WITH SOURCE_TOPIC "plant/line1"
        ADD TAG Setpoint
            WITH ADDRESS "200"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "FLOAT"
            WITH WRITABLE "true"
            WITH DESTINATION_TOPIC "plant/line1/setpoint/written"
```

MQTT publishes to `plant/line1/Setpoint` update registers 200–201. A master write surfaces on `plant/line1/setpoint/written` within 500 ms.

**Full plant server**

Listen on port 502 as unit 1 with multiple tags and connection settings:

```lot wrap  theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE PlantModbusServer WITH TYPE MODBUS_SERVER
    ADD MODBUS_SERVER_CONFIG
        WITH HOST "0.0.0.0"
        WITH PORT 502
        WITH UNIT_ID 1
        WITH MAX_CONNECTIONS 64
    ADD MAPPING ServerTags
        WITH EVERY 500 MILLISECONDS
        WITH SOURCE_TOPIC "plant/modbus"
        ADD TAG Temperature
            WITH ADDRESS "100"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "FLOAT"
        ADD TAG Setpoint
            WITH ADDRESS "200"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "FLOAT"
            WITH WRITABLE "true"
            WITH DESTINATION_TOPIC "plant/modbus/setpoint/set"
        ADD TAG Running
            WITH ADDRESS "0"
            WITH ADDRESS_TYPE "COIL"
            WITH DATA_TYPE "BOOL"
            WITH WRITABLE "true"
            WITH DESTINATION_TOPIC "plant/modbus/running/set"
```

Masters read `Temperature`; `Setpoint` and `Running` are bidirectional—MQTT drives them and master writes publish to their `DESTINATION_TOPIC` within 500 ms.

**Crash-resilient setpoints**

Persist master-written setpoints across broker restarts:

```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE ResilientServer WITH TYPE MODBUS_SERVER
    ADD MODBUS_SERVER_CONFIG
        WITH PORT 502
        WITH UNIT_ID 1
        WITH PERSISTENCE true
    ADD MAPPING Control
        WITH EVERY 500 MILLISECONDS
        WITH SOURCE_TOPIC "plant/line1"
        ADD TAG Setpoint
            WITH ADDRESS "200"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "FLOAT"
            WITH WRITABLE "true"
            WITH DESTINATION_TOPIC "plant/line1/setpoint/written"
        ADD TAG Temperature
            WITH ADDRESS "100"
            WITH ADDRESS_TYPE "INPUT_REGISTER"
            WITH DATA_TYPE "FLOAT"
```

After a restart, a master reading `Setpoint` (200–201) gets the last value it wrote. `Temperature` (100–101) refreshes from the live MQTT source, not the snapshot.

***

## How Data Flows

```mermaid theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
graph LR
    subgraph mqtt [MQTT]
        Topics[Topics]
    end

    subgraph broker [Coreflux Broker]
        Route[MODBUS_SERVER Route]
        Registers[Register / Coil Space]
    end

    subgraph ot [OT Masters]
        Master[SCADA / HMI / PLC]
    end

    Topics -->|publish sets value| Route
    Route --> Registers
    Registers -->|read / write| Master
    Master -->|write writable tag| Route
    Route -->|publish on EVERY cadence| Topics
```

Sync is **hybrid**—`WITH EVERY` controls outbound latency:

| Direction | Behavior |
| - | - |
| **MQTT → register (inbound)** | A publish on a tag's `SOURCE_TOPIC` updates the backing register or coil immediately. Works for every address type, including read-only `INPUT_REGISTER` and `DISCRETE_INPUT`. Retained MQTT values are seeded at startup. |
| **register → MQTT (outbound)** | When a master writes a `WRITABLE` `HOLDING_REGISTER` or `COIL`, the broker detects the change on the mapping's `WITH EVERY` cadence and publishes the decoded value to the tag's `DESTINATION_TOPIC` (rounded per `DECIMAL_PLACES`, retained). A master write surfaces on MQTT within one cadence interval. |

A master write never loops back into the register from MQTT, and an MQTT-driven change is never re-published to `DESTINATION_TOPIC` (direction guard).

On server routes, route status **Connected** means the endpoint is **listening**—not that a remote session is established.

***

## Server Configuration

All transport settings live in the `MODBUS_SERVER_CONFIG` block. A valid route needs only `WITH TYPE MODBUS_SERVER` plus a mapping with one tag—it listens on `0.0.0.0:502` as unit 1 by default.

```lot wrap focus={2-6} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE PlantModbusServer WITH TYPE MODBUS_SERVER
    ADD MODBUS_SERVER_CONFIG
        WITH HOST "0.0.0.0"
        WITH PORT 502
        WITH UNIT_ID 1
        WITH MAX_CONNECTIONS 64
```

### Connection parameters

**HOST** `string` (default: 0.0.0.0)

Bind interface to listen on (`0.0.0.0` = all interfaces). `IP` is accepted as an alias.

**PORT** `integer` (default: 502)

TCP listen port. Each server route claims its port exclusively—a second route on the same port is refused.

**MAX\_CONNECTIONS** `integer` (default: 64)

Best-effort soft cap on concurrent master connections. See [Connection-limit behavior](#connection-limit-behavior).

**UNIT\_ID** `integer` (default: 1)

Modbus unit ID the broker answers as (1–247). `SLAVE_ID` is accepted as an alias.

**PERSISTENCE** `boolean` (default: false)

Persist served values across broker restarts. See [Data persistence & recovery](#data-persistence--recovery).

**ONE\_BASED\_ADDRESSES** `boolean` (default: false)

Treat plain numeric tag addresses as one-based (address `1` = PDU register 0). Modicon-style addresses (`400001+`, `300001+`, `100001+`) are always decoded regardless of this flag. See [Address notation](#address-notation).

> [!NOTE]
>
> Modbus has no native authentication—there is no credential configuration. Bind to a specific interface and restrict access at the network layer; MQTT-side access is governed by HUB topic permissions. Use `GET ENV` / `GET SECRET` for environment-derived values (for example `WITH PORT GET ENV "MODBUS_PORT"`).

***

## Tag and Mapping Configuration

Each exposed register or coil is declared as an `ADD TAG`. Only declared addresses are served—reads or writes to undeclared addresses return a Modbus **Illegal Data Address** exception.

```lot wrap focus={7-20} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE PlantModbusServer WITH TYPE MODBUS_SERVER
    ADD MODBUS_SERVER_CONFIG
        WITH HOST "0.0.0.0"
        WITH PORT 502
        WITH UNIT_ID 1
        WITH MAX_CONNECTIONS 64
    ADD MAPPING ServerTags
        WITH EVERY 500 MILLISECONDS
        WITH SOURCE_TOPIC "modbus/server"
        ADD TAG Setpoint
            WITH ADDRESS "200"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "FLOAT"
            WITH SOURCE_TOPIC "modbus/server/setpoint"
            WITH WRITABLE "true"
            WITH DESTINATION_TOPIC "modbus/server/setpoint/set"
            WITH BYTE_ORDER "BIGENDIAN"
            WITH WORD_ORDER "BIGENDIAN"
            WITH DECIMAL_PLACES 2
            WITH DESCRIPTION "Operator setpoint"
        ADD TAG ProductCode
            WITH ADDRESS "300"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "STRING"
            WITH LENGTH 40
            WITH WRITABLE "true"
            WITH DESTINATION_TOPIC "modbus/server/product/code"
```

### Server vs. client semantics

| Setting | Client (`MODBUS_TCP`) | Server (`MODBUS_SERVER`) |
| - | - | - |
| Role | Master—dials out to a device | Slave—listens for masters |
| `HOST` / `IP` | Device IP to dial | **Bind** interface to listen on |
| `SOURCE_TOPIC` | Register value → MQTT (read) | MQTT → register (write into address space) |
| `DESTINATION_TOPIC` | MQTT → device write | Register → MQTT when a master writes |
| `EVERY` | Poll interval | Outbound sync cadence (master write → MQTT) |
| Transport | TCP / Serial (RTU) | TCP |

### Modbus address banks

| `ADDRESS_TYPE` | Bank | Element | Master access | Function codes |
| - | - | - | - | - |
| `HOLDING_REGISTER` | Holding registers | 16-bit word | read / write | 3, 6, 16 |
| `INPUT_REGISTER` | Input registers | 16-bit word | read-only | 4 |
| `COIL` | Coils | 1 bit | read / write | 1, 5, 15 |
| `DISCRETE_INPUT` | Discrete inputs | 1 bit | read-only | 2 |

### Address notation

Tag `ADDRESS` values are converted to the **PDU offset** the broker exposes on the wire. External Modbus masters always use zero-based PDU addresses in their read/write requests—the `ONE_BASED_ADDRESSES` flag only affects how you label tags in LoT, not how masters address the server.

Three styles are supported (same rules as the [Modbus TCP Client](./modbus-tcp-client#modbus-addressing) route):

| Style | Config | Tag address for PDU 0 | Tag address for PDU 99 |
| - | - | - | - |
| Zero-based plain (default) | `ONE_BASED_ADDRESSES false` | `"0"` | `"99"` |
| One-based plain | `ONE_BASED_ADDRESSES true` | `"1"` | `"100"` |
| Modicon holding register | either | `"400001"` | `"400100"` |

```lot wrap focus={2-12} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE PlantModbusServer WITH TYPE MODBUS_SERVER
    ADD MODBUS_SERVER_CONFIG
        WITH PORT 502
        WITH UNIT_ID 1
        WITH ONE_BASED_ADDRESSES true
    ADD MAPPING ServerTags
        WITH SOURCE_TOPIC "plant/modbus"
        ADD TAG Temperature
            WITH ADDRESS "1"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "FLOAT"
```

> [!NOTE]
>
> Modicon-style addresses are decoded the same way regardless of `ONE_BASED_ADDRESSES`. The flag only affects plain numeric addresses.

### Tag parameters

**ADDRESS** `string` *required*

Register or coil address. Plain numeric (0–65535 zero-based by default, or 1–65536 when `ONE_BASED_ADDRESSES true`), or Modicon notation (`400001+`, `300001+`, `100001+`). Multi-word types occupy consecutive PDU addresses from the resolved offset. See [Address notation](#address-notation).

**ADDRESS\_TYPE** `string` *required*

Which bank the tag lives in: `HOLDING_REGISTER`, `INPUT_REGISTER`, `COIL`, or `DISCRETE_INPUT`.

**DATA\_TYPE** `string` (default: INT16)

Wire type: `BOOL`, `INT16`, `UINT16`, `INT32`, `UINT32`, `INT64`, `UINT64`, `FLOAT`, `DOUBLE`, or `STRING`. `INT32`/`UINT32`/`FLOAT` span 2 registers; `INT64`/`UINT64`/`DOUBLE` span 4.

**LENGTH** `integer` (default: 40)

**STRING only.** Maximum string size in **bytes** (2 bytes per register). Register count is `ceil(bytes / 2)` (integer form: `(bytes + 1) / 2`). When omitted, defaults to **40 bytes** (20 registers) and logs a Warning. Alias: `STRING_SIZE`. Must resolve to at most **123 registers** (**246 bytes**), the FC16 write cap; larger values are rejected during route validation.

**SOURCE\_TOPIC** `string`

MQTT topic whose publishes set this register or coil. Defaults to `{mapping SOURCE_TOPIC}/{TagName}`.

**WRITABLE** `boolean` (default: false)

Allow masters to write the address. Only `HOLDING_REGISTER` and `COIL` are master-writable.

**DESTINATION\_TOPIC** `string`

Topic published to when a master writes this (writable) address.

**BYTE\_ORDER** `string` (default: BIGENDIAN)

Byte order within each register for multi-byte types: `BIGENDIAN` or `LITTLEENDIAN`.

**WORD\_ORDER** `string` (default: BIGENDIAN)

Word order across registers for 32-/64-bit types (high word first by default): `BIGENDIAN` or `LITTLEENDIAN`.

**DECIMAL\_PLACES** `integer` (default: 2)

Rounding applied to `FLOAT`/`DOUBLE` values published to `DESTINATION_TOPIC`.

**PERSISTENCE** `boolean`

Per-tag override of the route persistence default. Precedence: tag → mapping → route. See [Data persistence & recovery](#data-persistence--recovery).

**DESCRIPTION** `string`

Free-text documentation for the tag.

**EVERY** `duration` (default: 1 second)

On the mapping block: register → MQTT sync cadence for master writes.

### All four address types

One server exposing every bank type:

```lot wrap focus={4-22} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE MixedServer WITH TYPE MODBUS_SERVER
    ADD MAPPING Points
        WITH SOURCE_TOPIC "plant/mixed"
        ADD TAG Pressure
            WITH ADDRESS "100"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "FLOAT"
        ADD TAG MotorSpeed
            WITH ADDRESS "50"
            WITH ADDRESS_TYPE "INPUT_REGISTER"
            WITH DATA_TYPE "UINT16"
        ADD TAG PumpEnable
            WITH ADDRESS "0"
            WITH ADDRESS_TYPE "COIL"
            WITH DATA_TYPE "BOOL"
            WITH WRITABLE "true"
            WITH DESTINATION_TOPIC "plant/mixed/pump/cmd"
        ADD TAG DoorClosed
            WITH ADDRESS "0"
            WITH ADDRESS_TYPE "DISCRETE_INPUT"
            WITH DATA_TYPE "BOOL"
```

All four points are fed from `plant/mixed/<TagName>`; only `PumpEnable` accepts master writes (→ `plant/mixed/pump/cmd`).

### STRING tag example

Reserve a writable 40-byte holding-register range for barcode values a master can write with FC16:

```lot wrap focus={7} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE ScannerServer WITH TYPE MODBUS_SERVER
    ADD MAPPING ScannerTags
        ADD TAG Barcode
            WITH ADDRESS "0"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "STRING"
            WITH LENGTH 40
            WITH WRITABLE "true"
            WITH DESTINATION_TOPIC "scanner/barcode"
```

`WITH LENGTH 40` reserves holding registers 0–19 (`ceil(40 / 2) = 20` registers). A master FC16 write to those 20 registers is decoded and published to `scanner/barcode`. `STRING_SIZE` is accepted as an alias for `LENGTH`.

***

## Protocol Conformance

This server implements Modbus Application Protocol **Conformance Class 0 and Class 1** over Modbus/TCP—the interoperability bar for standard masters:

| Class | Function codes | Supported |
| - | - | - |
| Class 0 | FC 03 Read Holding Registers, FC 16 Write Multiple Registers | ✅ |
| Class 1 | FC 01 Read Coils, FC 02 Read Discrete Inputs, FC 04 Read Input Registers, FC 05 Write Single Coil, FC 06 Write Single Register | ✅ |
| Class 2 | FC 15 Write Multiple Coils | ✅ |

Writes are accepted only on `WRITABLE` `HOLDING_REGISTER`/`COIL` addresses. Undeclared addresses and writes to read-only banks return exception **02 (Illegal Data Address)**.

> [!NOTE]
>
> Modbus/TCP is **plaintext**—the protocol has no native authentication or encryption. Secure deployments by binding to a specific interface and restricting access at the network layer (firewall, VLAN, VPN). Modbus/TCP Security (TLS on port 802) is not yet implemented.

***

## Data Persistence & Recovery

By default a server route holds register and coil values **in memory only**—a broker restart resets every address to zero. Set `WITH PERSISTENCE true` on `MODBUS_SERVER_CONFIG` to snapshot served values and restore them on startup.

### Ownership-aware policy

What is safe to persist depends on who owns the value:

| Bank | Owner | Default | On restart |
| - | - | - | - |
| `HOLDING_REGISTER` / `COIL`, `WRITABLE true` | Master-written setpoint lives only in the slave | **Persisted** | Restored **authoritatively**—wins over retained MQTT |
| `INPUT_REGISTER` / `DISCRETE_INPUT`, and non-writable banks | Fed from live MQTT | **Not persisted** | Recovered from **live MQTT** (retained message / next publish) |

Master-written setpoints survive a restart; live telemetry always reflects the current MQTT source.

### Per-tag overrides

`WITH PERSISTENCE true|false` on an individual `ADD TAG` overrides the route default:

```lot wrap  theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE OverrideServer WITH TYPE MODBUS_SERVER
    ADD MODBUS_SERVER_CONFIG
        WITH PORT 502
        WITH PERSISTENCE true
    ADD MAPPING Registers
        WITH SOURCE_TOPIC "plant/line1"
        ADD TAG Setpoint
            WITH ADDRESS "100"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "FLOAT"
            WITH WRITABLE "true"
        ADD TAG LastGoodTemp
            WITH ADDRESS "200"
            WITH ADDRESS_TYPE "INPUT_REGISTER"
            WITH DATA_TYPE "FLOAT"
            WITH PERSISTENCE "true"
        ADD TAG ScratchSetpoint
            WITH ADDRESS "300"
            WITH ADDRESS_TYPE "HOLDING_REGISTER"
            WITH DATA_TYPE "INT16"
            WITH WRITABLE "true"
            WITH PERSISTENCE "false"
```

* `Setpoint` — restored authoritatively to the last master-written value.
* `LastGoodTemp` — served from snapshot until `plant/line1/LastGoodTemp` next publishes.
* `ScratchSetpoint` — always comes back at zero.

### Caching slow or infrequently-updated sources

Broker-fed tags are not persisted by default. When a source updates infrequently—an hourly energy total, a shift-latched value—force-persist with `WITH PERSISTENCE "true"` so the last received value is re-served on cold start until the next live publish overrides it.

```lot wrap focus={6-12} theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE MeterServer WITH TYPE MODBUS_SERVER
    ADD MODBUS_SERVER_CONFIG
        WITH PORT 502
        WITH PERSISTENCE true
    ADD MAPPING Meters
        WITH SOURCE_TOPIC "plant/energy"
        ADD TAG TotalEnergy
            WITH ADDRESS "100"
            WITH ADDRESS_TYPE "INPUT_REGISTER"
            WITH DATA_TYPE "FLOAT"
            WITH PERSISTENCE "true"
```

If every tag in a mapping is a slow source, set `WITH PERSISTENCE "true"` once on the `ADD MAPPING` block instead of per tag.

### Storage & durability

* Values are written through a non-blocking background worker on every change (rapid updates coalesce to the latest value).
* The snapshot lives in an encrypted-at-rest database under the broker data directory.
* A snapshot is keyed to the route's tag definitions by a fingerprint. If addresses or types change between runs, the stale snapshot is discarded rather than replayed into reshaped addresses.

***

## Verify Your Server

**1. Deploy the route**

Add the route definition to your broker and confirm route status shows **Connected** (the endpoint is listening).

**2. Connect with a Modbus master**

Use any standards-compliant Modbus TCP master. Connect to `<HOST>:<PORT>` and address the route's `UNIT_ID` (default 1).

**3. Confirm MQTT → register**

Publish a test value to a bound `SOURCE_TOPIC`. Read the corresponding address from the master and confirm the decoded value matches.

**4. Confirm register → MQTT**

For a writable tag with a `DESTINATION_TOPIC`, write a value from the master. Subscribe to the destination topic with MQTT Explorer or any MQTT client and confirm the publish arrives within one `WITH EVERY` interval.

***

## Limitations

> [!WARNING]
>
> Each `MODBUS_SERVER` route binds exclusively to its `PORT`. A second route on the same port fails to start. Give each route its own port.

***

## Troubleshooting

Publish `-checkRouteConnection <RouteName>` to `$SYS/Coreflux/Command`. For a server route this reports whether the hosted endpoint is **listening**, along with the endpoint, port, unit ID, and current connection count.

> **Route status never Connected**
>
> * **Port in use** — Another route or process owns `PORT`. Choose a free port or stop the conflicting service.
> * **Bind error** — Check broker logs. Ensure `HOST` is a valid interface on the machine.

> **Master cannot connect**
>
> Confirm `HOST`/`PORT`, network path, and firewall rules. The master must target the route's `UNIT_ID` (default 1).

> **Master reads zero or stale values**
>
> Registers default to zero until first set. Verify `SOURCE_TOPIC` has published; retained MQTT values are seeded at startup.

> **Master write not appearing on MQTT**
>
> * Confirm the tag is `WRITABLE` on a `HOLDING_REGISTER` or `COIL` with a `DESTINATION_TOPIC`.
> * The value surfaces within one `WITH EVERY` interval—verify the cadence.

> **Write rejected (Modbus exception)**
>
> Read-only banks (`INPUT_REGISTER`, `DISCRETE_INPUT`) and non-writable tags reject master writes by design. Undeclared addresses return Illegal Data Address.
>
> **Accepted master writes:** FC06 (Write Single Register) and FC16 (Write Multiple Registers) on `WRITABLE` `HOLDING_REGISTER` tags; FC05 (Write Single Coil) and FC15 (Write Multiple Coils) on `WRITABLE` `COIL` tags.
>
> **Rejected master writes:** any write to `INPUT_REGISTER` or `DISCRETE_INPUT`; writes to non-`WRITABLE` tags; writes to addresses outside the declared tag range (including partial FC16 ranges that extend beyond a STRING tag's reserved registers).

> **Master FC16 write fails on a STRING tag (Illegal Data Address)**
>
> **Problem:** A master writes a STRING with FC16, but the server returns Illegal Data Address.
>
> **Check:** Confirm the full holding-register range is declared by the tag. STRING capacity is `ceil(LENGTH / 2)` registers. An omitted `WITH LENGTH` reserves the default **40 bytes** (**20 registers**) and logs a Warning; older broker versions reserved only one register when the setting was omitted. `STRING_SIZE` remains accepted as an alias.
>
> **Solution:** Set `WITH LENGTH` to the complete byte width the master writes, up to **246 bytes** (**123 registers**). Rejected undeclared holding-register ranges log `Master write rejected: … Check WITH LENGTH on STRING tags.`

> **Master activity logging**
>
> The broker logs master activity for this route in the broker log:
>
> * **Accepted writes** — FC06/FC16 on writable `HOLDING_REGISTER` tags; FC05/FC15 on writable `COIL` tags, with bank-aware function-code labels.
> * **Rejected writes** — read-only banks, non-writable tags, and undeclared address ranges (including partial FC16 spans beyond a STRING tag's reserved registers).
> * **Rejected reads** — undeclared or out-of-range read requests.
> * **Undeclared holding-register ranges** — rejection messages include `Check WITH LENGTH on STRING tags` when sizing is the likely cause.
> * **Connections** — open/close events with the active connection count from the existing watchdog.
>
> These logs **cannot identify the master's source IP** — it is not available to the server route.

> **Wrong numeric value**
>
> Align `BYTE_ORDER` and `WORD_ORDER` with the master's expectation. Default is big-endian, high word first.

> **Address mismatch (Illegal Data Address)**
>
> Confirm tag `ADDRESS` values match how the master addresses the server. Use `WITH ONE_BASED_ADDRESSES true` when your tag labels follow device-manual numbering (register 1 = address `"1"`). Modicon notation (`400100`) works regardless of the flag.

> **Connection-limit behavior**
>
> `MAX_CONNECTIONS` is a **best-effort** soft cap—the underlying library exposes no per-connection control, so exceeding the cap is logged rather than enforced at accept time. Restrict concurrency at the network layer if a hard cap is required.

***

## Best Practices

> **Bind to a specific interface in production**
>
> Prefer `WITH HOST "192.168.10.5"` over `0.0.0.0` when only one network should reach the server.

> **Keep environment values out of route text**
>
> Use `WITH PORT GET ENV "MODBUS_PORT"` instead of hard-coding environment-specific ports.

> **Declare each exposed address explicitly**
>
> Only declared addresses are served. One `ADD TAG` per register or coil a master needs.

> **Make tags WRITABLE only when needed**
>
> Leave `WRITABLE` unset for read-only points. Non-writable holding registers and read-only banks reject writes with a Modbus exception.

> **Match byte and word order to the master**
>
> Set `WORD_ORDER "LITTLEENDIAN"` when the master expects low word first. Default is big-endian, high word first.

> **Size WITH EVERY to outbound latency**
>
> Use a shorter interval for responsive control loops; a longer one to reduce churn for slowly-changing values.

> **Reserve ports per route**
>
> Document which route owns which port to avoid startup conflicts when adding routes.

> **Restrict access at the network layer**
>
> Modbus TCP has no authentication. Place the server behind a firewall or VLAN and rely on HUB topic permissions for MQTT-side security.

> **Monitor route status**
>
> Watch `$SYS/Coreflux/Routes/{RouteName}/status`. `Connected` means the server is listening—investigate `Error` immediately (usually a port conflict).

***

## Next Steps

> **[Modbus TCP Client](./modbus-tcp-client)**
>
> Connect to remote Modbus devices and poll register data into MQTT.

> **[Modbus Serial Client](./modbus-serial-client)**
>
> Connect to remote Modbus RTU devices over serial.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.