# Research: Modbus RTU (serial) support

**Status:** Research / roadmap. Not implemented.
**Date:** 2026-07-15
**Author:** research sweep
**Scope:** Add Modbus **RTU** (serial + RTU-over-TCP) to the OtOpcUa server.

---

## TL;DR

Modbus RTU is **an added transport mode on the existing `ModbusDriver`, not a new
driver.** The existing driver already splits the *protocol data unit* (PDU: function
code + data) from the *transport* (socket + MBAP framing) behind a clean
`IModbusTransport` seam, and the driver injects transports through a
`Func<ModbusDriverOptions, IModbusTransport>` factory. RTU is a second
`IModbusTransport` implementation that swaps MBAP framing for
`[address][PDU][CRC-16]` framing over a serial line (or a raw TCP socket, for
RTU-over-TCP). The register model, function codes, data-type codecs, read planner,
coalescing, deadband, write path, and OPC UA materialisation are **100% reused
unchanged**. Browseable = **NO** (flat register space, no discovery — identical to
Modbus TCP). The pragmatic primary path for a containerised Linux server is
**RTU-over-TCP to a serial→Ethernet gateway**, with direct `System.IO.Ports` serial as
a secondary path for bare-metal / device-mapped deployments. This is very likely the
**lowest-effort item on the driver roadmap**; the only real risk is serial-line
timing/behaviour on Linux and in containers.

---

## 1. Extend-vs-new-driver verdict — **EXTEND**

### Why the existing code makes this easy

The Modbus driver is already layered exactly the way you'd want in order to add a
transport. The seam is `IModbusTransport`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs`):

```csharp
public interface IModbusTransport : IAsyncDisposable
{
    Task ConnectAsync(CancellationToken ct);
    Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct);
}
```

The interface doc comment is explicit that it takes **"a PDU (function code + data,
excluding the 7-byte MBAP header)"** and returns the **response PDU** — "the transport
owns transaction-id pairing, framing, and socket I/O." That is precisely the RTU-vs-TCP
boundary. Everything above the seam is transport-neutral:

- **`ModbusDriver.cs`** builds every PDU as a raw `byte[]` of `[functionCode, ...data]`
  — FC01/02/03/04/05/06/15/16 encoders (`ReadRegisterBlockAsync`, `ReadBitBlockAsync`,
  the FC05/06/15/16 write paths, the FC03→bit-swap→FC06 RMW) all call
  `transport.SendAsync(unitId, pdu, ct)` and decode the returned PDU. None of them
  touch MBAP, sockets, or CRC.
- **`ModbusTcpTransport.cs`** is the *only* place the 7-byte MBAP header, the
  transaction-id counter (`_nextTx`), and `TcpClient`/`NetworkStream` I/O live. It
  wraps each PDU as `[TxId][Proto=0][Length][UnitId] + PDU`, single-flights via a
  `SemaphoreSlim _gate`, and does socket-level reconnect/retry.
- **The driver injects the transport**: `ModbusDriver`'s constructor takes
  `Func<ModbusDriverOptions, IModbusTransport>? transportFactory`, defaulting to
  `o => new ModbusTcpTransport(...)`. Tests already substitute in-memory fakes through
  this same hook.

So the entire protocol/codec/planner/health/OPC-UA surface is transport-agnostic
today. Adding RTU means adding **one class** behind the existing seam plus the config
plumbing to select it.

### The concrete refactor

There is essentially **no refactor of existing code needed** — the split is already
done. The work is additive:

1. **New `ModbusRtuTransport : IModbusTransport`** (serial). Wraps a
   `System.IO.Ports.SerialPort`. `ConnectAsync` opens the port with the configured
   baud/data-bits/parity/stop-bits. `SendAsync` frames the ADU as
   `[unitId][PDU][CRC-lo][CRC-hi]`, enforces the ≥3.5-character inter-frame silence
   before transmit, writes, then reads the response, strips the address + validates and
   strips the CRC-16, and returns the bare PDU. Reuses the same single-flight `_gate`
   pattern (mandatory on RTU — see §2).
2. **New `ModbusRtuOverTcpTransport : IModbusTransport`** (RTU tunnelled over a socket).
   Identical RTU framing (`[address][PDU][CRC]`, no MBAP, no TxId) but the byte stream
   rides a `TcpClient`/`NetworkStream` to a serial gateway instead of a COM port. This
   can share the socket-management, keepalive, idle-disconnect, and reconnect/backoff
   machinery already in `ModbusTcpTransport` — the *only* difference from
   `ModbusTcpTransport` is the ADU framing (CRC instead of MBAP) and the absence of a
   transaction id. Consider extracting the socket lifecycle into a small shared base or
   helper so both TCP variants share it; the MBAP-vs-CRC framing is the swap point.
3. **A CRC-16 helper** (`ModbusCrc.Compute(ReadOnlySpan<byte>)`) — the standard Modbus
   CRC with polynomial `0xA001` (reflected `0x8005`), CRC appended low-byte-first. Put
   it in `...Driver.Modbus` (or `.Addressing`).
4. **Transport-mode selection** in `ModbusDriverOptions` + the config DTO + the factory
   (`ModbusDriverFactoryExtensions.CreateInstance`) — a `Transport` discriminator
   (`Tcp` | `Rtu` | `RtuOverTcp`) plus the serial parameters, wiring the default
   `transportFactory` closure to pick the right transport (see §3).
5. **AdminUI**: extend `ModbusDriverPage.razor` with a serial parameters panel shown
   when `Transport != Tcp`. No new page — same driver page.

**Framing subtlety worth calling out.** In Modbus TCP the MBAP `Length` field tells the
transport exactly how many response bytes to read (`ModbusTcpTransport.SendOnceAsync`
reads a 7-byte header, then `Length-1` more). **RTU has no length field.** The RTU
transport must determine the response length either (a) by parsing the function code
and byte-count field (read responses carry a byte-count; FC05/06/15/16 echoes are
fixed-length; an exception response is a fixed 5 bytes with the high bit set on the FC),
or (b) by reading until an inter-character idle gap (T1.5/T3.5) elapses. Function-code-
aware length calculation is the robust choice and is simplest given the driver already
knows the FC set. This is the single genuinely new piece of logic RTU introduces.

**Verdict: extend the existing `ModbusDriver` with two new `IModbusTransport`
implementations + a transport selector.** A sibling driver would duplicate the entire
codec/planner/health/materialisation surface for zero benefit — the protocol above the
wire is identical.

---

## 2. Capability mapping — identical to TCP, four deltas

RTU is *the same Modbus application protocol* as TCP: same register model (Coils /
Discrete Inputs / Input Registers / Holding Registers), same function codes
(FC01–06, 15, 16, and the exception PDU convention), same data types, same read/write
semantics. Everything the driver does above the transport seam is unchanged.

| Capability | Modbus TCP (today) | Modbus RTU (added) | Delta? |
|---|---|---|---|
| Register model + function codes | ✅ | ✅ identical | none |
| Read (FC01/02/03/04) | ✅ | ✅ | none — same PDU |
| Write (FC05/06/15/16) | ✅ | ✅ | none — same PDU |
| Data-type codecs, byte order, arrays, strings, BCD, bit-in-register | ✅ | ✅ | none |
| Read coalescing / auto-prohibit / deadband / WriteOnChangeOnly | ✅ | ✅ | none |
| Connectivity probe (FC03@0) | ✅ | ✅ | none — goes through `SendAsync` |
| **Transport** | TCP socket | serial line / RTU-over-TCP socket | **serial vs socket** |
| **Framing** | 7-byte MBAP header + TxId; TCP guarantees integrity | `[addr][PDU][CRC-16]`; app-level CRC | **CRC-16 vs MBAP** |
| **Unit/slave id** | often 1 (one device per socket); gateway multiplexing exists | **central** — one bus, multiple drop slaves addressed by unit id | **more prominent** |
| **Timing** | TCP framing; no inter-frame constraint | **≥3.5-char inter-frame silence**, T1.5 inter-char | **timing-based framing** |
| Browse/discovery | none | none | none (see §4) |
| Historian / alarms | out of scope | out of scope | none |

**The four deltas in detail:**

1. **Transport** — a `SerialPort` (or a socket to a gateway) replaces the `TcpClient`.
   The socket-reconnect / keepalive / idle-disconnect logic in `ModbusTcpTransport` is
   TCP-specific and does **not** apply to a serial line (a COM port doesn't "drop" the
   way a NAT'd socket does); the RTU serial transport has its own simpler
   open/reopen-on-error model. RTU-over-TCP *does* reuse the socket lifecycle.
2. **Framing** — RTU wraps `[slaveAddress(1)][PDU][CRC-16-lo][CRC-16-hi]`. There is no
   MBAP header and no transaction id. The CRC-16 (poly `0xA001` reflected, appended
   **low byte first**) replaces TCP's transport-level integrity. The transport computes
   CRC on send and validates on receive, treating a CRC mismatch as a
   desync/communication error (map onto the existing `ModbusTransportDesyncException` /
   `BadCommunicationError` handling).
3. **Unit-id semantics** — on RTU the unit/slave id is *the* addressing mechanism for a
   multi-drop bus; a single serial line commonly hosts several slaves. The driver
   already supports this: `ModbusTagDefinition.UnitId` is a per-tag override and
   `ResolveUnitId` + `BuildSlaveHostName` already key per-slave resilience by
   `host:port/unitN`. Multi-drop RTU "just works" with the existing per-tag UnitId
   plumbing — the read planner already refuses to coalesce across UnitIds. (For RTU the
   per-slave "host" key becomes `COMx/unitN` or `gatewayHost:port/unitN`.)
4. **Timing** — RTU frames are delimited by silence, not length. Requests must be
   preceded by ≥3.5 character-times of idle; responses are read until the same idle gap
   (or, preferably, by function-code-aware length). Character time depends on
   baud/word-length: at 9600 baud, 8-N-1 (10 bits/char), 3.5 chars ≈ 3.6 ms. **Above
   19200 baud the spec fixes T3.5 at 1.75 ms and T1.5 at 750 µs** rather than scaling
   further. Single-flight is mandatory: RTU has no transaction id to correlate an
   interleaved response, so at most one transaction may be in flight on a bus — the
   existing `_gate` semaphore already provides this.

**Read + write are both fully supported**, exactly as with Modbus TCP.

Sources for framing/timing/CRC claims:
[ModbusKit RTU/ASCII/TCP comparison](https://modbuskit.com/en/blog/modbus-rtu-tcp-ascii-comprehensive-comparison),
[ModbusSimulator RTU vs TCP](https://modbussimulator.com/blog/modbus-rtu-vs-tcp-comparison-guide),
[Industrial Monitor Direct — TCP vs RTU-over-TCP](https://industrialmonitordirect.com/blogs/knowledgebase/modbus-tcp-vs-modbus-rtu-over-tcpip-protocol-differences).

---

## 3. Config JSON shape

Per-tag config is **unchanged** — the existing `ModbusTagDefinition` / `ModbusTagDto`
(region, address, data type, byte order, `UnitId` per-tag override, etc.) already
covers everything RTU needs. The additions are **driver-level transport fields** only.

Proposed additions to the driver config DTO (`ModbusDriverConfigDto`):

| Field | Type | Applies to | Notes |
|---|---|---|---|
| `Transport` | `"Tcp"` \| `"Rtu"` \| `"RtuOverTcp"` | all | Discriminator. Default `"Tcp"` (back-compat). |
| `SerialPort` | string | Rtu | COM port / device path, e.g. `"COM3"` or `"/dev/ttyUSB0"`. |
| `BaudRate` | int | Rtu | e.g. 9600, 19200, 38400, 115200. |
| `DataBits` | int | Rtu | Usually 8 (RTU). |
| `Parity` | `"None"`\|`"Even"`\|`"Odd"` | Rtu | Modbus spec default **Even**; many devices use None. |
| `StopBits` | `"One"`\|`"Two"` | Rtu | 1 with parity, 2 without, per spec. |
| `Host` / `Port` | string / int | Tcp, **RtuOverTcp** | Reused for RtuOverTcp — the serial-gateway's socket. |
| `InterFrameDelayMs` | int? | Rtu | Optional override of the computed T3.5 silence for slow/RF links. |

`Host`/`Port`/`UnitId`/`TimeoutMs`/`MaxRegistersPerRead`/... all stay. Serial fields are
ignored when `Transport=Tcp`; `Host`/`Port` are ignored when `Transport=Rtu`.

### Example A — direct serial RTU

```json
{
  "Transport": "Rtu",
  "SerialPort": "/dev/ttyUSB0",
  "BaudRate": 19200,
  "DataBits": 8,
  "Parity": "Even",
  "StopBits": "One",
  "UnitId": 1,
  "TimeoutMs": 1000,
  "Tags": [
    { "Name": "Flow",  "AddressString": "40001:F:ABCD", "Writable": false },
    { "Name": "Setpt", "AddressString": "40010:F",      "Writable": true  },
    { "Name": "Pump2Run", "Region": "Coils", "Address": 0, "DataType": "Bool",
      "Writable": true, "UnitId": 2 }
  ]
}
```

`Pump2Run` shows a second drop slave (UnitId 2) on the same bus — no extra transport
config, just the per-tag `UnitId` override the driver already honours.

### Example B — RTU-over-TCP to a serial→Ethernet gateway

```json
{
  "Transport": "RtuOverTcp",
  "Host": "10.20.0.50",
  "Port": 4001,
  "UnitId": 1,
  "TimeoutMs": 1500,
  "Tags": [
    { "Name": "Temp",  "AddressString": "30001:I" },
    { "Name": "Alarm", "Region": "DiscreteInputs", "Address": 5, "DataType": "Bool" }
  ]
}
```

Same `Host`/`Port` shape as Modbus TCP, but the wire frames are raw RTU (address +
CRC, no MBAP). This is the difference between a gateway operating in "Modbus TCP"
translation mode (use `Transport: "Tcp"`) versus "transparent/RTU passthrough" mode
(use `Transport: "RtuOverTcp"`).

**Note:** standard Modbus/TCP masters cannot parse RTU-over-TCP frames and vice-versa —
the two are wire-incompatible, so the `Transport` discriminator must match the
gateway's configured mode ([Industrial Monitor Direct](https://industrialmonitordirect.com/blogs/knowledgebase/modbus-tcp-vs-modbus-rtu-over-tcpip-protocol-differences)).

---

## 4. BROWSEABILITY VERDICT — **NO**

**Modbus RTU is not browseable — no address-space browser is warranted.** This is
identical to Modbus TCP. Modbus (any transport) exposes a **flat, untyped register
space** (coils / discrete inputs / input registers / holding registers, addressed
0–65535) with **no discovery protocol** — there is no way to enumerate which registers
exist, what they mean, or their data types. The mapping from register → engineering
meaning lives entirely in the device's vendor documentation, not on the wire.

The existing driver reflects this exactly: `ModbusDriverOptions.Tags` is documented as
"Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly
these," and `DiscoverAsync` simply materialises the authored tag list into a flat
`Modbus` folder. `RediscoverPolicy` is `Once`. RTU changes none of this. The AdminUI's
`ModbusAddressPickerBody` is an **address *builder*** (grammar helper for composing a
register string), **not** a live browser — and that stays correct for RTU too.

No browser. Authoring stays manual tag entry / address-builder assisted, same as TCP.

---

## 5. Cross-platform serial reality

The server can run on Linux (docker) as well as Windows, so serial-port availability
matters.

- **`System.IO.Ports.SerialPort` is cross-platform** on .NET (5+): it ships the
  built-in implementation for **Windows and Linux**, distributed as the
  `System.IO.Ports` NuGet package (current `10.0.x` for .NET 10). On Linux it binds
  `/dev/tty*` devices via termios.
  ([NuGet System.IO.Ports](https://www.nuget.org/packages/system.io.ports/),
  [MS Q&A](https://learn.microsoft.com/en-us/answers/questions/1444956/is-system-io-ports-currently-only-support-on-windo))
- **macOS is the weak platform.** Serial support on macOS/MacCatalyst is limited/flaky
  (baud-rate quirks, `MacCatalyst` unsupported); developers typically fall back to
  virtual serial ports for testing.
  ([Mark's Blog — virtual serial ports on macOS](https://mallibone.com/post/dotnet-on-macos),
  [dotnet/runtime #43719](https://github.com/dotnet/runtime/issues/43719)).
  This matters only for the **dev machine** (this repo's dev is macOS) — production
  targets are Windows/Linux. RTU unit-testing on macOS should use fakes /
  RTU-over-TCP, not a real COM port.
- **Containers add a device-mapping hurdle.** A serial device must be explicitly passed
  into the container: `docker run --device=/dev/ttyUSB0` (or a compose `devices:`
  entry), and USB-serial adapters can re-enumerate (`/dev/ttyUSB0` ↔ `ttyUSB1`) across
  reboots/replug, so a stable `udev` symlink or `/dev/serial/by-id/...` path is
  advisable. On Windows containers COM passthrough is notoriously unreliable.
  ([Docker forums — expose host serial port](https://forums.docker.com/t/how-to-expose-host-serial-port-to-container-correctly/81588),
  [Portainer device mapping](https://oneuptime.com/blog/post/2026-03-20-map-host-devices-containers-portainer/view))

**Why RTU-over-TCP is the pragmatic primary path for this server.** OtOpcUa is deployed
as a containerised server (docker-dev rig, Linux docker host at `10.100.0.35`) that is
generally **not physically attached to an RS-485 bus.** The idiomatic industrial
topology is a **serial→Ethernet gateway** (Moxa NPort, Digi One, Lantronix, USR-TCP232,
etc.) sitting on the RS-485 multidrop and exposing it over TCP. The server then talks
**RTU-over-TCP** to the gateway — a plain socket, zero host-device mapping, no
`System.IO.Ports` dependency on the container, no udev fragility, and it reuses the
already-hardened socket lifecycle (keepalive / idle-disconnect / reconnect-backoff)
from `ModbusTcpTransport`. Direct `System.IO.Ports` serial should ship too (for
bare-metal Windows/Linux installs with a local COM port or device-mapped adapter), but
**RTU-over-TCP is the path most deployments will actually use**, and it's the lower-risk
one to build and test.

---

## 6. Test-fixture strategy

Three complementary options, in rough order of value for this repo:

1. **RTU-over-TCP against pymodbus (highest ROI, no serial hardware).** The existing
   Modbus fixture already runs `pymodbus.simulator` in docker
   (`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/`,
   binding `:5020`). pymodbus can serve an **RTU-framed TCP** server, so an
   `rtu_over_tcp` profile alongside the existing `standard`/`dl205`/`mitsubishi`/
   `exception_injection` profiles exercises the real RTU framing + CRC path end-to-end
   with **no serial anything** — same harness, same docker host, same
   `lmxopcua-fix up modbus <profile>` workflow. This validates the CRC codec and the
   function-code-aware framing, which is the only genuinely new logic.

2. **Virtual serial pair on Linux for the direct-serial transport.** `socat -d -d
   pty,raw,echo=0 pty,raw,echo=0` creates a linked `/dev/pts/N` ↔ `/dev/pts/M` pair;
   point a pymodbus **RTU serial** slave at one end and `ModbusRtuTransport` at the
   other. (`com0com` is the Windows equivalent.) This is the only way to cover the real
   `System.IO.Ports` open/read/write path without hardware, and it runs in a Linux
   container or on the docker host. macOS dev can't easily do this — run it on the
   Linux docker host or in CI.

3. **A dedicated RTU slave simulator** — `diagslave` (serial + TCP RTU modes) or
   ModbusPal — for manual / soak testing against a virtual pair or a real USB-serial
   adapter. Useful for the eventual live-gate but not for unit CI.

**Unit-level:** the CRC-16 helper gets a table-driven unit test against known Modbus CRC
vectors, and `ModbusRtuTransport`/`ModbusRtuOverTcpTransport` can be tested with an
in-memory duplex stream fake (the driver already fakes `IModbusTransport`; here we fake
one level lower, the byte stream, to assert framing + CRC + response parsing). No PLC
needed for the bulk of coverage.

**Recommended CI shape:** unit tests for CRC + RTU framing (fake stream) + an
`rtu_over_tcp` pymodbus docker profile for integration; defer real-serial (socat pair /
hardware) to an env-gated live suite like the other driver live gates.

---

## 7. Effort / risk

**Effort: LOW — likely the lowest-effort item on the driver roadmap.** Because the PDU
layer is already transport-agnostic and injected, the net-new code is small and
localised:

- `ModbusCrc` helper (~30 lines) + unit test.
- `ModbusRtuOverTcpTransport` — can largely reuse `ModbusTcpTransport`'s socket
  lifecycle; the delta is CRC framing + FC-aware response length (no TxId). Extracting
  the shared socket lifecycle into a base/helper is the main refactor, and it's
  mechanical.
- `ModbusRtuTransport` (serial) — `SerialPort` open + the same framing + T3.5 timing.
- Config: `Transport` discriminator + serial fields on `ModbusDriverOptions`, the DTO,
  and the factory closure (~1 file each).
- AdminUI: a serial-parameters panel on the existing `ModbusDriverPage.razor`, shown
  when `Transport != Tcp`, + the matching config-model round-trip. **Watch the known
  enum-serialization trap** (per project memory: driver pages serialize enums
  numerically but factory DTOs are string-typed — add `JsonStringEnumConverter` so
  `Transport`/`Parity`/`StopBits` round-trip as strings, mirroring OpcUaClient).
- **Zero** changes to codecs, planner, coalescing, health, materialisation, HistoryRead,
  or the address parser.

**Risks (all manageable):**

- **RTU response framing without a length field** is the one novel piece of logic —
  get the function-code-aware length calculation (and exception-PDU short-frame
  detection) right, or fall back to idle-gap timeout. Cover with the fake-stream unit
  tests.
- **Serial timing on Linux / in containers** — `System.IO.Ports` on Linux honours
  read timeouts but fine-grained T1.5/T3.5 inter-character gating is best-effort; slow
  or long RS-485/RF runs may need the `InterFrameDelayMs` override. This is the top
  residual risk and the reason to lead with RTU-over-TCP.
- **macOS dev can't exercise real serial** — mitigated by making RTU-over-TCP the
  primary tested path and running the socat/serial suite on the Linux docker host / CI,
  not the dev Mac.
- **USB-serial device enumeration** in containers (`/dev/ttyUSB*` renumbering) — a
  deployment/ops concern, addressed with `--device` + stable `by-id` paths, not a code
  risk.

**Bottom line:** small, additive, low-risk. Ship RTU-over-TCP first (reuses the hardened
socket path, no host-device dependency, testable on the existing pymodbus docker
harness), then direct `System.IO.Ports` serial for bare-metal installs.

---

## Key source files (for the implementer)

- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs` — the seam RTU
  plugs into.
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs` — the reference
  transport; RTU-over-TCP reuses its socket lifecycle.
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs` — transport-agnostic PDU
  builders + factory injection point (`transportFactory`).
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs` +
  `ModbusEquipmentTagParser.cs` — where the transport-mode + serial options are added.
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs` — DTO +
  transport selection.
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor`
  — driver config UI to extend.
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/` — pymodbus
  fixture to add an `rtu_over_tcp` profile to.

## Sources

- [ModbusKit — RTU vs ASCII vs TCP comparison](https://modbuskit.com/en/blog/modbus-rtu-tcp-ascii-comprehensive-comparison)
- [ModbusSimulator — RTU vs TCP guide](https://modbussimulator.com/blog/modbus-rtu-vs-tcp-comparison-guide)
- [Industrial Monitor Direct — Modbus TCP vs RTU-over-TCP protocol differences](https://industrialmonitordirect.com/blogs/knowledgebase/modbus-tcp-vs-modbus-rtu-over-tcpip-protocol-differences)
- [NModbus (C# Modbus, supports serial RTU/ASCII/TCP/UDP)](https://github.com/NModbus/NModbus)
- [NuGet — System.IO.Ports](https://www.nuget.org/packages/system.io.ports/)
- [Microsoft Q&A — System.IO.Ports platform support](https://learn.microsoft.com/en-us/answers/questions/1444956/is-system-io-ports-currently-only-support-on-windo)
- [Mark's Blog — .NET virtual serial ports on macOS](https://mallibone.com/post/dotnet-on-macos)
- [dotnet/runtime #43719 — macOS SerialPort baud limitation](https://github.com/dotnet/runtime/issues/43719)
- [Docker forums — expose host serial port to container](https://forums.docker.com/t/how-to-expose-host-serial-port-to-container-correctly/81588)
- [Portainer — mapping host serial/USB devices to containers](https://oneuptime.com/blog/post/2026-03-20-map-host-devices-containers-portainer/view)
