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

# Modbus

> Using ModbusDevice for Modbus TCP and RTU device communication

# ModbusDevice

ModbusDevice is a config-driven Modbus TCP/RTU client that provides **alias-based register access**, automatic background polling, and full integration with the `instro` publisher system. Instead of writing raw Modbus function calls, you define your device's register map in a JSON configuration file (or build it programmatically in Python) and access registers by semantic names.

<Note>
  [Modbus Application Protocol Specification](https://www.modbus.org/modbus-specifications)
</Note>

## Quickstart

Publishing Modbus data to Nominal Core with instro

<Tabs>
  <Tab title="Code">
    ```python theme={null}
    import time

    from instro.lib.publishers import NominalCorePublisher
    from instro.modbus import ModbusDevice

    RID = "<dataset_rid>" # Replace with your dataset RID.

    # Instantiate from a JSON config. autostart opens the connection and begins polling.
    # It requires a `timing` section in the config (present in my_device.json below).
    modbus_device = ModbusDevice(config="my_device.json", autostart=True)

    # Add publisher to flow data to Nominal Core
    modbus_device.add_publisher(NominalCorePublisher(dataset_rid=RID))

    # Allow some time for background daemon reads
    time.sleep(10)

    modbus_device.close()
    ```
  </Tab>

  <Tab title="Config (my_device.json)">
    ```json theme={null}
    {
      "version": 1,
      "protocol": "modbus",
      "device": {
        "name": "example_device",
        "description": "Example device for instro docs",
        "manufacturer": "Nominal",
        "model": "Sim Device 3000"
      },
      "connection": {
        "transport": "tcp",
        "host": "<device ip address>",
        "port": 502,
        "unit_id": 1,
        "timeout": 3.0
      },
      "timing": {
        "poll_interval": 1.0
      },
      "registers": [
        {
          "name": "sample_channel",
          "starting_address": 0,
          "register_type": "holding",
          "data_type": "float32",
          "poll": true
        }
      ]
    }
    ```
  </Tab>
</Tabs>

## Key Benefits

* **Config-driven**: Define registers, connection, and timing in a single JSON file or build the config programmatically in Python
* **Alias-based access**: Read and write registers by name (e.g., `"temperature"`) instead of raw addresses
* **Automatic scaling**: Apply linear transformations between raw register values and physical units
* **Read groups**: Batch multiple registers into a single Modbus transaction for efficiency
* **Write safety**: Enforce min/max limits and use human-readable string-to-value mappings
* **Byte ordering control**: Handle vendor-specific byte/word/long swap requirements
* **Transparent reconnect**: Dropped TCP connections are re-established on the next operation without any extra code

## When to Use Each Feature

Most devices can be brought online with just a connection, a few registers, and `autostart=True`. The features below each solve a specific problem you'll run into as soon as you're working with real hardware. Use this table to jump to the right section.

| If you're trying to...                                                     | Reach for...                                                                                    |
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Stream live data to Nominal Core with the shortest possible setup          | [`autostart=True`](#lifecycle-pattern) + [`add_publisher()`](#creating-a-modbusdevice-instance) |
| Read raw ADC counts or tick values as physical/engineering units           | [`scale`](#linear-scaling)                                                                      |
| Protect equipment from out-of-range or wrong-type writes                   | [`write_min` / `write_max`](#write-limits)                                                      |
| Replace magic numbers with human-readable labels for mode/state writes     | [`write_value_map`](#write-value-maps)                                                          |
| Unpack a packed status word into individual boolean channels               | [`bitmap`](#bitmap-extraction)                                                                  |
| Minimize round-trips when polling many adjacent registers                  | [`read_group`](#read-groups)                                                                    |
| Share one device config across test benches with different networking      | Pass [`connection` separately](#creating-a-modbusdevice-instance) to the constructor            |
| Work with a device that uses little-endian or "Modbus-style" word ordering | [`byte_swap` / `word_swap` / `long_swap`](#byte-ordering)                                       |
| Throttle commands to a device that processes writes sequentially           | [`write_delay_ms`](#timing-section)                                                             |
| Prototype a config without real hardware                                   | The built-in [simulator](#prototyping-without-a-device)                                         |

## Key Concepts

### Lifecycle Pattern

The typical ModbusDevice workflow follows this pattern:

1. **Define your device**: Create a JSON config file or build a `ModbusConfig` in Python
2. **`ModbusDevice(config)`**: Instantiate the client with your config (and optionally a separate `connection`)
3. **`open()`**: Establish connection to the Modbus device
4. **`start()`**: Begin background polling (if timing is configured)
5. **Read or Write**: Access registers by alias with automatic type handling and scaling
6. **`close()`**: Disconnect and stop any background polling

<Tip>
  Pass `autostart=True` to combine steps 3 and 4. The connection opens and polling begins immediately on instantiation. This requires a `timing` section in the config.
</Tip>

### Register Types

Modbus defines four register types, each occupying a separate address space:

| Register Type | Function Codes                   | Access     | Size   | Description                        |
| ------------- | -------------------------------- | ---------- | ------ | ---------------------------------- |
| **Holding**   | FC03 (read) / FC06, FC16 (write) | Read/Write | 16-bit | General-purpose writable registers |
| **Input**     | FC04 (read)                      | Read-only  | 16-bit | Sensor/process input registers     |
| **Coil**      | FC01 (read) / FC05 (write)       | Read/Write | 1-bit  | Boolean outputs (on/off)           |
| **Discrete**  | FC02 (read)                      | Read-only  | 1-bit  | Boolean inputs (status bits)       |

### Supported Data Types

Multi-register data types span consecutive 16-bit registers:

| Data Type | Register Count | Range                                                          |
| --------- | -------------- | -------------------------------------------------------------- |
| `uint16`  | 1              | 0 to 65,535                                                    |
| `int16`   | 1              | -32,768 to 32,767                                              |
| `uint32`  | 2              | 0 to 4,294,967,295                                             |
| `int32`   | 2              | -2,147,483,648 to 2,147,483,647                                |
| `uint64`  | 4              | 0 to 2^64-1                                                    |
| `int64`   | 4              | -2^63 to 2^63-1                                                |
| `float32` | 2              | IEEE 754 single precision                                      |
| `float64` | 4              | IEEE 754 double precision                                      |
| `bool`    | 1              | Any non-zero register value is read as `true`, zero as `false` |

<Note>
  Coil and discrete input registers are always read and written as booleans regardless of `data_type`, their `data_type` field is ignored. Use the `bool` data type for a **holding or input** register that stores a 0/1 flag in a regular 16-bit register rather than a coil.
</Note>

### Byte Ordering

Modbus devices can vary in how they order bytes within multi-register values. ModbusDevice provides three swap options to handle these differences:

* **`byte_swap`** - Swaps bytes within each 16-bit word. Applies to all data types.
* **`word_swap`** - Swaps 16-bit words within 32-bit or 64-bit values. Not valid for 16-bit types.
* **`long_swap`** - Swaps 32-bit halves within 64-bit values. Only valid for 64-bit types (`uint64`, `int64`, `float64`).

Swaps are applied in order during reads: byte swap, then word swap, then long swap. The reverse order is applied during writes.

#### `byte_swap`: Swap bytes within each 16-bit word

Applies to all data types. Example with a `float32` value (`0x40490FDB` = 3.14159):

```
Register 0    Register 1
┌───────────┐ ┌───────────┐
│ 0x40 0x49 │ │ 0x0F 0xDB │ ← default (big-endian)
└───────────┘ └───────────┘
      ╳             ╳
┌───────────┐ ┌───────────┐
│ 0x49 0x40 │ │ 0xDB 0x0F │ ← byte_swap: true
└───────────┘ └───────────┘
```

#### `word_swap`: Swap 16-bit words

Applies to 32-bit and 64-bit types. Example with the same `float32`:

```
Register 0    Register 1
┌───────────┐ ┌───────────┐
│ 0x40 0x49 │ │ 0x0F 0xDB │ ← default
└───────────┘ └───────────┘
             ╳
┌───────────┐ ┌───────────┐
│ 0x0F 0xDB │ │ 0x40 0x49 │ ← word_swap: true
└───────────┘ └───────────┘
```

#### `long_swap`: Swap 32-bit halves

Only applies to 64-bit types. Example with a `float64` value spanning 4 registers:

```
Register 0    Register 1    Register 2    Register 3
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ 0x40 0x09 │ │ 0x21 0xFB │ │ 0x54 0x44 │ │ 0x2D 0x18 │ ← default
└───────────┘ └───────────┘ └───────────┘ └───────────┘
 ────── hi 32-bit ────────   ─────── lo 32-bit ───────
                           ╳
 ────── lo 32-bit ────────   ─────── hi 32-bit ───────
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ 0x54 0x44 │ │ 0x2D 0x18 │ │ 0x40 0x09 │ │ 0x21 0xFB │ ← long_swap: true
└───────────┘ └───────────┘ └───────────┘ └───────────┘
```

#### Combining swaps

Swaps can be combined. For a `float32` with both `byte_swap` and `word_swap`:

```
Register 0    Register 1
┌───────────┐ ┌───────────┐
│ 0x40 0x49 │ │ 0x0F 0xDB │ ← default (big-endian)
└───────────┘ └───────────┘
        ① byte_swap
┌───────────┐ ┌───────────┐
│ 0x49 0x40 │ │ 0xDB 0x0F │ ← bytes swapped within words
└───────────┘ └───────────┘
        ② word_swap
┌───────────┐ ┌───────────┐
│ 0xDB 0x0F │ │ 0x49 0x40 │ ← words swapped (little-endian)
└───────────┘ └───────────┘
```

<Tip>
  If you are seeing unexpected values when reading registers, try enabling `byte_swap` or `word_swap` in your register definition. Consult your device's documentation for its byte ordering convention.
</Tip>

### Linear Scaling

Many devices store physical values as scaled integers to save bandwidth, like a pressure sensor that returns `2500` to mean `25.00 PSI`, or a temperature sensor that returns `731` to mean `73.1 °F`. Linear scaling lets your application code work in physical units while the driver handles the conversion.

```
physical = offset + (gain * raw)
```

```json theme={null}
{
  "name": "pressure",
  "starting_address": 10,
  "register_type": "input",
  "data_type": "uint16",
  "scale": {"type": "linear", "gain": 0.01, "offset": 0}
}
```

With the config above, `device.read("pressure")` returns `25.0` when the register holds `2500`. Writes are reversed automatically. `device.write("pressure", 25.0)` sends `2500` to the device.

Scaling is applied automatically on reads (raw to physical) and reversed on writes (physical to raw).

<Warning>
  Scaling is only supported on **holding** and **input** registers. Coils and discrete inputs are single-bit values and cannot be scaled.
</Warning>

### Bitmap Extraction

Many devices pack several status or alarm flags into a single 16-bit status register to save address space. Bit 0 might be `motor_running`, bit 1 `fault_active`, bit 7 `at_setpoint`, etc. Rather than unpacking those bits by hand in application code, you can declare a `bitmap` and have each bit published as its own named channel alongside the raw register value.

Registers with `uint16` data type on `holding` or `input` register types can include an optional `bitmap` to extract individual bits as separate channels. Each bit is published as its own channel with a value of `0` or `1`.

```json theme={null}
{
  "name": "status_word",
  "starting_address": 100,
  "register_type": "holding",
  "data_type": "uint16",
  "bitmap": [
    { "name": "motor_running", "bit_index": 0 },
    { "name": "fault_active", "bit_index": 1 },
    { "name": "at_setpoint", "bit_index": 7 }
  ]
}
```

Each entry in `bitmap` defines:

| Field       | Required | Description                             |
| ----------- | -------- | --------------------------------------- |
| `name`      | Yes      | Channel name for this bit               |
| `bit_index` | Yes      | 0-based bit position from LSB (0 to 15) |

When this register is read, the full `uint16` value is published on the `status_word` channel, and each bit is published as a separate channel (e.g., `device_name.motor_running`). Bitmap names must be unique across the entire configuration and must not collide with register names.

### Read Groups

Registers that are physically adjacent can be grouped so they are read in a **single Modbus transaction** instead of individual requests. This reduces bus traffic and improves poll cycle time.

Reach for this when you're polling many registers on a single device and want to keep total round-trip time low. The difference is especially pronounced on RTU links or over high-latency networks, where each extra request adds measurable delay.

Assign the same `read_group` string to each register in the group:

```json theme={null}
{
  "registers": [
    {
      "name": "inlet_temp",
      "starting_address": 0,
      "register_type": "input",
      "data_type": "float32",
      "read_group": "temperatures"
    },
    {
      "name": "outlet_temp",
      "starting_address": 2,
      "register_type": "input",
      "data_type": "float32",
      "read_group": "temperatures"
    }
  ]
}
```

**Rules:**

* All registers in a group must have the **same `register_type`** (e.g., all `"input"` or all `"holding"`).
* All registers in a group must have `poll: true` (the default).
* The total span of a group must not exceed the Modbus read limit: **125 registers** for holding/input, **2000 bits** for coils/discrete inputs.
* Group names must be unique across the entire config. Pick a separate name for each group, even when the registers occupy different Modbus address spaces.

During background polling, one bulk read covers the entire address range of each group. Each register is then decoded from the bulk response.

<Tip>
  If you're polling 30 adjacent holding registers at 100 ms intervals, placing them all in a single `read_group` reduces the poll cycle from 30 round-trips per interval to 1, a large win for devices on slow links or under tight polling requirements.
</Tip>

### Write Value Maps

Industrial devices commonly use integer codes to select modes or states, such as `0 = off`, `1 = standby`, `2 = run`, and `3 = flush`. Hard-coding those integers in application code is error-prone and hides intent. A `write_value_map` lets callers pass the label (`"run"`) while the driver sends the correct integer.

Registers can include a `write_value_map` that maps **human-readable string labels** to raw numeric values. This replaces magic numbers with meaningful names:

```json theme={null}
{
  "name": "operating_mode",
  "starting_address": 102,
  "register_type": "holding",
  "data_type": "uint16",
  "write_value_map": {
    "off": 0,
    "standby": 1,
    "run": 2,
    "flush": 3
  }
}
```

With this config, you write strings instead of raw numbers:

```python theme={null}
device.write("operating_mode", "run")   # writes 2 to the register
device.write("operating_mode", "off")   # writes 0 to the register
```

You can still write raw numeric values directly, the map is only used when a string is passed.

| Field             | Required | Description                                        |
| ----------------- | -------- | -------------------------------------------------- |
| `write_value_map` | No       | Dictionary mapping string labels to numeric values |

<Note>
  Write value maps are only allowed on `holding` registers. Map values must be unique within the register and must fall within any configured `write_min`/`write_max` limits.
</Note>

### Write Limits

When a write can physically affect equipment (moving an actuator, setting a temperature, commanding a flow rate), you usually have a safe operating range that's narrower than the data type's range. Declaring `write_min` and `write_max` catches out-of-range writes on the client before they reach the device, replacing an accidental `999.0` setpoint with a clean `ValueError`.

Registers can include `write_min` and/or `write_max` to enforce bounds on written values. This provides protection against accidental out-of-range writes:

```json theme={null}
{
  "name": "setpoint",
  "starting_address": 100,
  "register_type": "holding",
  "data_type": "float32",
  "write_min": 50.0,
  "write_max": 250.0
}
```

```python theme={null}
device.write("setpoint", 150.0)  # OK
device.write("setpoint", 999.0)  # Raises ValueError
```

| Field       | Required | Default | Description                                                     |
| ----------- | -------- | ------- | --------------------------------------------------------------- |
| `write_min` | No       |         | Minimum allowed write value (in physical units, before scaling) |
| `write_max` | No       |         | Maximum allowed write value (in physical units, before scaling) |

Write limits are validated **before** scaling is applied, so they represent the physical value you're writing, not the raw register value. Write limits are only allowed on `holding` registers.

## Configuration

ModbusDevice is configured through a JSON file. Every config file must include:

* **`version`**: identifies the config schema version, ensuring forward compatibility as the format evolves.
* **`protocol`**: must be `"modbus"`. This field identifies which protocol the config is intended for, so that an incorrect config file is caught immediately with a clear error rather than failing with confusing type mismatches.

### Full Configuration Example

```json theme={null}
{
  "version": 1,
  "protocol": "modbus",
  "device": {
    "name": "heat_exchanger",
    "description": "Heat exchanger monitoring and control",
    "manufacturer": "Acme Thermal",
    "model": "HX-200"
  },
  "connection": {
    "transport": "tcp",
    "host": "<device ip address>",
    "port": 502,
    "unit_id": 1,
    "timeout": 3.0
  },
  "timing": {
    "poll_interval": 1.0,
    "write_delay_ms": 100
  },
  "registers": [
    {
      "name": "inlet_temp",
      "starting_address": 0,
      "register_type": "input",
      "data_type": "float32",
      "word_swap": true,
      "read_group": "temperatures"
    },
    {
      "name": "outlet_temp",
      "starting_address": 2,
      "register_type": "input",
      "data_type": "float32",
      "word_swap": true,
      "read_group": "temperatures"
    },
    {
      "name": "flow_rate",
      "starting_address": 4,
      "register_type": "input",
      "data_type": "uint16",
      "scale": {
        "type": "linear",
        "gain": 0.1,
        "offset": 0
      }
    },
    {
      "name": "setpoint",
      "starting_address": 100,
      "register_type": "holding",
      "data_type": "float32",
      "word_swap": true,
      "write_min": 50.0,
      "write_max": 250.0
    },
    {
      "name": "operating_mode",
      "starting_address": 102,
      "register_type": "holding",
      "data_type": "uint16",
      "write_value_map": {
        "off": 0,
        "standby": 1,
        "run": 2,
        "flush": 3
      },
      "write_min": 0,
      "write_max": 3
    },
    {
      "name": "pump_enable",
      "starting_address": 0,
      "register_type": "coil"
    },
    {
      "name": "status_register",
      "starting_address": 200,
      "register_type": "input",
      "data_type": "uint16",
      "bitmap": [
        {"name": "pump_running", "bit_index": 0},
        {"name": "alarm_high_temp", "bit_index": 1},
        {"name": "alarm_low_flow", "bit_index": 2},
        {"name": "fault", "bit_index": 15}
      ]
    }
  ]
}
```

### Device Section

Metadata about the physical device. The `name` field is used as a prefix for channel names when publishing data.

| Field          | Required | Description                              |
| -------------- | -------- | ---------------------------------------- |
| `name`         | Yes      | Device name, used as channel name prefix |
| `description`  | No       | Human-readable description               |
| `manufacturer` | No       | Device manufacturer                      |
| `model`        | No       | Device model number                      |

### Connection Section

#### TCP Connection

| Field       | Required | Default | Description                     |
| ----------- | -------- | ------- | ------------------------------- |
| `transport` | Yes      |         | Must be `"tcp"`                 |
| `host`      | Yes      |         | IP address or hostname          |
| `port`      | No       | `502`   | TCP port (1 to 65535)           |
| `unit_id`   | No       | `1`     | Modbus slave/unit ID (0 to 255) |
| `timeout`   | No       | `3.0`   | Response timeout in seconds     |

#### RTU (Serial) Connection

| Field       | Required | Default | Description                                     |
| ----------- | -------- | ------- | ----------------------------------------------- |
| `transport` | Yes      |         | Must be `"rtu"`                                 |
| `port`      | Yes      |         | Serial port path (see examples below)           |
| `baudrate`  | No       | `9600`  | Serial baud rate                                |
| `parity`    | No       | `"N"`   | Parity: `"N"` (none), `"E"` (even), `"O"` (odd) |
| `stopbits`  | No       | `1`     | Stop bits: `1` or `2`                           |
| `bytesize`  | No       | `8`     | Data bits: `5`, `6`, `7`, or `8`                |
| `unit_id`   | No       | `1`     | Modbus slave/unit ID (0 to 255)                 |
| `timeout`   | No       | `3.0`   | Response timeout in seconds                     |

**Common serial port paths:**

| Platform | Example Paths                      | Description                                |
| -------- | ---------------------------------- | ------------------------------------------ |
| Windows  | `"COM3"`, `"COM4"`                 | COM ports (check Device Manager)           |
| Linux    | `"/dev/ttyUSB0"`, `"/dev/ttyUSB1"` | USB-to-serial adapters (FTDI, CH340, etc.) |
| Linux    | `"/dev/ttyS0"`, `"/dev/ttyS1"`     | Built-in serial ports                      |
| Linux    | `"/dev/ttyACM0"`                   | USB CDC/ACM devices (Arduino, etc.)        |
| macOS    | `"/dev/tty.usbserial-1410"`        | USB-to-serial adapters                     |
| macOS    | `"/dev/cu.usbserial-1410"`         | USB-to-serial adapters (callout/outgoing)  |
| macOS    | `"/dev/tty.usbmodem14101"`         | USB modem devices                          |
| macOS    | `"/dev/cu.usbmodem14101"`          | USB modem devices (callout/outgoing)       |

<Tip>
  On Linux and macOS, you can list available serial ports with `ls /dev/tty*`. On Windows, check **Device Manager → Ports (COM & LPT)** to find the correct COM port number.
</Tip>

### Register Definitions

Each register in the `registers` array defines a named access point for a specific Modbus register or group of registers.

| Field              | Required | Default     | Description                                                                          |
| ------------------ | -------- | ----------- | ------------------------------------------------------------------------------------ |
| `name`             | Yes      |             | Unique alias for this register                                                       |
| `description`      | No       |             | Optional human-readable description of the register                                  |
| `starting_address` | Yes      |             | Modbus register address (0 to 65535)                                                 |
| `register_type`    | No       | `"holding"` | `"holding"`, `"input"`, `"coil"`, or `"discrete"`                                    |
| `data_type`        | No       | `"uint16"`  | See [Supported Data Types](#supported-data-types)                                    |
| `byte_swap`        | No       | `false`     | Swap bytes within 16-bit words                                                       |
| `word_swap`        | No       | `false`     | Swap 16-bit words (32/64-bit types only)                                             |
| `long_swap`        | No       | `false`     | Swap 32-bit halves (64-bit types only)                                               |
| `scale`            | No       |             | Optional linear scaling (holding/input only)                                         |
| `bitmap`           | No       |             | Extract individual bits as separate channels (`uint16` holding/input registers only) |
| `poll`             | No       | `true`      | Include in background polling                                                        |
| `write_min`        | No       |             | Minimum allowed write value (holding only)                                           |
| `write_max`        | No       |             | Maximum allowed write value (holding only)                                           |
| `write_value_map`  | No       |             | String-to-number mapping for writes (holding only)                                   |
| `read_group`       | No       |             | Group ID for batched reads (see [Read Groups](#read-groups))                         |

### Timing Section

| Field            | Required | Default | Description                                               |
| ---------------- | -------- | ------- | --------------------------------------------------------- |
| `poll_interval`  | Yes      |         | Polling interval in seconds (0.01 to 10.0)                |
| `write_delay_ms` | No       | `0`     | Delay in milliseconds applied after every write operation |

When the `timing` section is present, all registers with `poll: true` are read at the specified interval by the background daemon. Polled measurements are automatically published and buffered for retrieval.

The `write_delay_ms` field is useful for devices that need a short delay between consecutive writes (e.g., controllers that process commands sequentially).

### Validation Rules

The configuration is validated at load time. Invalid configs produce clear error messages.

* Register names must be unique within the configuration.
* Registers of the same type must not have overlapping address ranges. Different register types can share addresses (they occupy separate address spaces in Modbus).
* `word_swap` is not valid for 16-bit data types (`uint16`, `int16`, `bool`).
* `long_swap` is only valid for 64-bit data types (`uint64`, `int64`, `float64`).
* Scaling is not allowed on `coil` or `discrete` register types.
* `bitmap` is only supported on `uint16` holding or input registers. Bitmap names must be unique across the entire configuration and must not collide with register names. Duplicate `bit_index` values within a single bitmap are not allowed.
* `write_min`, `write_max`, and `write_value_map` are only allowed on `holding` registers. `write_min` must be less than or equal to `write_max`. Write value map entries must have unique values and must fall within any configured write limits.
* All registers in a `read_group` must share the same `register_type` and must have `poll: true`. A group's address span must not exceed the Modbus read limit (125 registers for holding/input, 2000 bits for coils/discrete).

## Connection Resilience

Modbus TCP connections drop for all the usual reasons, such as network blips, device reboots, and power cycles on a PLC. ModbusDevice handles this transparently. When a read or write hits a transport error, the driver closes the dead socket and pymodbus opens a fresh one on the next operation. No custom retry loop, no explicit `open()` after a disconnect.

Your code just needs to tolerate the occasional `OSError` or `ConnectionError` being raised from `read()` / `write()`. If you're running with `autostart=True` and background polling, transient errors from a single poll are logged without tearing down the daemon. The next tick will succeed as soon as the device comes back.

## Prototyping Without a Device

Not every development environment has a PLC sitting on the bench. For config authoring, CI tests, and demos, the Modbus package ships with a lightweight TCP simulator you can run alongside your code:

```bash theme={null}
python -m instro.modbus.sim_server
```

The sim server listens on `127.0.0.1:5020` and exposes holding registers, input registers, coils, and discrete inputs seeded with representative values. Point any TCP config at it by overriding the connection at construction:

```python theme={null}
from instro.modbus import ModbusDevice

device = ModbusDevice(
    config="my_device.json",
    connection={"transport": "tcp", "host": "127.0.0.1", "port": 5020},
)
```

## Creating a ModbusDevice Instance

<Tabs>
  <Tab title="From JSON File">
    ```python theme={null}
    from instro.modbus import ModbusDevice

    device = ModbusDevice(config="my_device.json")
    device.open()
    ```
  </Tab>

  <Tab title="From JSON + Separate Connection">
    ```python theme={null}
    from instro.modbus import ModbusDevice

    # Connection is separate: useful when config is shared across environments
    connection = {"transport": "tcp", "host": "192.168.1.100", "port": 502}
    device = ModbusDevice(config="my_device.json", connection=connection)
    device.open()
    ```
  </Tab>

  <Tab title="Programmatic Config">
    ```python theme={null}
    from instro.modbus import ModbusConfig, ModbusDevice
    from instro.modbus import (
        DeviceInfo, LinearScale, RegisterDef, TCPConnection, TimingConfig,
    )

    config = ModbusConfig(
        device=DeviceInfo(name="my_device", manufacturer="Acme", model="X-100"),
        connection=TCPConnection(host="192.168.1.100", port=502, unit_id=1),
        timing=TimingConfig(poll_interval=0.5),
        registers=[
            RegisterDef(
                name="temperature",
                starting_address=0,
                register_type="holding",
                data_type="float32",
                write_min=0.0,
                write_max=500.0,
            ),
            RegisterDef(
                name="mode",
                starting_address=100,
                register_type="holding",
                data_type="uint16",
                write_value_map={"off": 0, "standby": 1, "run": 2},
            ),
        ],
    )

    device = ModbusDevice(config, autostart=True)
    ```
  </Tab>
</Tabs>

### Parameters

| Parameter    | Required | Default       | Description                                                                                                                                                      |
| ------------ | -------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `config`     | Yes      |               | Path to a JSON file, a `dict`, or a `ModbusConfig` instance                                                                                                      |
| `connection` | No       |               | A `TCPConnection`, `RTUConnection`, or `dict`. Takes precedence over the connection in the config. Required if the config does not include a connection section. |
| `name`       | No       | `device.name` | Override the instrument name used for channel naming                                                                                                             |
| `publishers` | No       | `None`        | List of publishers to attach at construction time                                                                                                                |
| `autostart`  | No       | `False`       | If `True` and the config contains a `timing` section, opens the connection and starts background polling on instantiation                                        |

<Tip>
  The `connection` parameter allows you to separate device-specific register maps from environment-specific connection details. One JSON config can be shared across test benches while each environment passes its own connection.
</Tip>

Use `add_publisher()` to attach a [publisher](/instrumentation/publishers) for automatic data streaming:

```python theme={null}
from instro.lib.publishers import NominalCorePublisher

device.add_publisher(NominalCorePublisher(dataset_rid="<dataset_rid>"))
```

## Examples

### Basic Read and Write

```python theme={null}
from instro.modbus import ModbusDevice

device = ModbusDevice(config="temperature_controller.json")
device.open()

# Read a register value by alias
temp = device.read("process_temp")
print(f"Temperature: {temp.latest}")

# Write a register by alias
device.write("setpoint", 75.0)

device.close()
```

<Note>
  **Published channels.** Every read/write produces a channel keyed under `{name}.{descriptor}`, where `{name}` is the constructor argument and `{descriptor}` is built from the names in your `ModbusConfig`. The descriptors are:

  | Method                                           | Descriptor        | Type      |
  | ------------------------------------------------ | ----------------- | --------- |
  | `read(alias)` (per register)                     | `{register.name}` | telemetry |
  | `read(alias)` (per bit in a register's `bitmap`) | `{bit.name}`      | telemetry |
  | `write(alias, value)`                            | `{alias}.cmd`     | command   |

  Modbus had no v1.0 channel-naming change. [`legacy_naming`](/instrumentation/library#backwards-compatible-channel-naming-legacy_naming) is accepted but is a no-op for this category.
</Note>

### Background Polling with Publishers

When a `timing` section is present in the config, you can use `autostart=True` for a one-liner setup, or call `open()` and `start()` explicitly for more control:

<Tabs>
  <Tab title="With autostart">
    ```python theme={null}
    from instro.modbus import ModbusDevice
    from instro.lib.publishers import NominalCorePublisher

    # autostart opens the connection and begins polling immediately
    device = ModbusDevice(config="heat_exchanger.json", autostart=True)
    device.add_publisher(NominalCorePublisher(dataset_rid="ri.datasets.main.dataset.abc123"))

    # All registers with poll: true are now being read at the configured interval
    # and published to Nominal Core automatically.

    # You can also retrieve buffered poll data:
    recent_temps = device.get_channel("heat_exchanger.inlet_temp", length=100)

    # When done, close stops polling and disconnects
    device.close()
    ```
  </Tab>

  <Tab title="Explicit open/start">
    ```python theme={null}
    from instro.modbus import ModbusDevice
    from instro.lib.publishers import NominalCorePublisher

    device = ModbusDevice(config="heat_exchanger.json")
    device.add_publisher(NominalCorePublisher(dataset_rid="ri.datasets.main.dataset.abc123"))

    # Open connection and start polling when ready
    device.open()
    device.start()

    recent_temps = device.get_channel("heat_exchanger.inlet_temp", length=100)

    device.close()
    ```
  </Tab>
</Tabs>

### Write Value Maps and Write Limits

Write value maps eliminate magic numbers, and write limits provide protection against accidental out-of-range writes:

```python theme={null}
from instro.modbus import ModbusDevice

device = ModbusDevice(config="heat_exchanger.json", autostart=True)

# Write using human-readable string labels
device.write("operating_mode", "run")
device.write("operating_mode", "standby")

# Write limits protect against out-of-range values
device.write("setpoint", 150.0)  # OK: within 50.0 to 250.0
try:
    device.write("setpoint", 999.0)  # Raises ValueError
except ValueError as e:
    print(f"Rejected: {e}")

device.close()
```

### RTU (Serial) Connection

The only difference for RTU is in the config file. The Python code is identical:

```json theme={null}
{
  "version": 1,
  "protocol": "modbus",
  "device": {
    "name": "field_sensor",
    "description": "RS-485 field temperature sensor"
  },
  "connection": {
    "transport": "rtu",
    "port": "/dev/ttyUSB0",
    "baudrate": 9600,
    "parity": "N",
    "stopbits": 1,
    "bytesize": 8,
    "unit_id": 1,
    "timeout": 2.0
  },
  "registers": [
    {
      "name": "temperature",
      "starting_address": 0,
      "register_type": "input",
      "data_type": "float32",
      "poll": true
    }
  ]
}
```

### Real-World Example: Watlow F4T Thermal Controller

This example demonstrates programming a thermal profile on a Watlow F4T controller using write value maps and write delays. The config uses `write_delay_ms: 300` because the F4T processes commands sequentially and `word_swap: true` for its byte ordering convention.

```python theme={null}
import time

from instro.modbus import ModbusDevice

connection = {"transport": "tcp", "host": "192.168.5.222", "port": 502}
f4t = ModbusDevice(config="watlow_f4t.json", connection=connection, autostart=True)

try:
    # Set zone 1 to auto mode using write_value_map (no magic numbers)
    f4t.write("z1_control_mode", "auto")

    # Write a setpoint with built-in write_min/write_max protection
    f4t.write("z1_setpoint", 150.0)

    # Start a thermal profile
    f4t.write("profile_start_profile_num", 1)
    f4t.write("profile_start", "start")

    # Monitor: polled registers stream to publishers automatically
    while True:
        time.sleep(1)

except KeyboardInterrupt:
    f4t.write("profile_pause_terminate", "terminate")
finally:
    f4t.close()
```

## Method Reference

| Method                                                                               | Purpose                                                                                                                         |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `ModbusDevice(config, connection=None, name=None, publishers=None, autostart=False)` | Create a new Modbus client from a JSON config file, `dict`, or `ModbusConfig` instance. Optionally pass a separate connection.  |
| `add_publisher(publisher)`                                                           | Attach a publisher for automatic data streaming                                                                                 |
| `open()`                                                                             | Open connection to the Modbus device (TCP or RTU)                                                                               |
| `close()`                                                                            | Close connection and stop background polling                                                                                    |
| `read(alias, **kwargs)`                                                              | Read a register by alias, apply scaling, return `Measurement`                                                                   |
| `write(alias, value, **kwargs)`                                                      | Write a value to a register by alias (accepts strings for `write_value_map` registers), apply reverse scaling, return `Command` |
| `unit_id`                                                                            | Property: returns the Modbus unit/slave ID from config                                                                          |
| `get_channel(channel_name, length=1, wait_for_new_samples=False, timeout=10.0)`      | Retrieve buffered poll data for a channel                                                                                       |
| `start()`                                                                            | Start background polling daemon                                                                                                 |
| `stop()`                                                                             | Stop background polling daemon                                                                                                  |

## Error Handling

ModbusDevice raises descriptive errors for common issues:

| Error                                                                                     | Cause                                                                                                                                                  |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ConnectionError: Failed to connect to Modbus device at {target}`                         | Device unreachable, connection refused, or (for RTU) serial port cannot be opened. `{target}` is `host:port` for TCP and the serial port path for RTU. |
| `RuntimeError: Modbus client not connected. Call open() first.`                           | Attempting to read/write before opening                                                                                                                |
| `KeyError: Register '{alias}' not found. Available: [...]`                                | Alias not defined in config                                                                                                                            |
| `ValueError: Register '{alias}' is read-only`                                             | Attempting to write to an input or discrete register                                                                                                   |
| `ValueError: Register '{alias}' value {v} is below write_min ({min})`                     | Value below configured `write_min`                                                                                                                     |
| `ValueError: Register '{alias}' value {v} is above write_max ({max})`                     | Value above configured `write_max`                                                                                                                     |
| `KeyError: '{value}' is not a valid value for register '{alias}'`                         | String not found in `write_value_map`                                                                                                                  |
| `KeyError: Register '{alias}' has no write_value_map. Cannot write string '{value}'`      | String value passed to a register that has no `write_value_map` defined                                                                                |
| `TypeError: Register '{alias}' is a bool/coil type but got float`                         | Incorrect value type for coil/discrete                                                                                                                 |
| `TypeError: Register '{alias}' is an integer type but got float`                          | Float with fractional part written to integer register                                                                                                 |
| `ValueError: Register '{alias}' value {v} is out of range for {data_type} [{min}, {max}]` | Value exceeds the bounds of the register's data type                                                                                                   |
| `RuntimeError: Modbus error ... IllegalDataAddress (0x02)`                                | Device rejected the register address                                                                                                                   |

<Tip>
  When writing to integer registers that have scaling configured, the physical value is converted to a raw integer value. Ensure the scaled result produces a whole number. For example, with `gain: 0.01` and `offset: 0`, writing `50.0` produces raw value `5000`, which is valid.
</Tip>
