Skip to content

CircuitPython Modbus TCP

pyrung.circuitpy can generate a Modbus TCP server, client, or both for the P1AM-200 via the P1AM-ETH shield. The register layout matches a real Click PLC — C-more HMIs, pyclickplc, and SCADA systems connect without translation. See CircuitPython Dialect for the base hardware model and code generation.

Hardware requirements

The P1AM-ETH shield provides a W5500 Ethernet controller on SPI with chip-select on board.D5. Static IPv4 only — no DHCP. The adafruit_wiznet5k library must be installed on the CIRCUITPY drive alongside the CircuitPython P1AM library.

Server

from pyrung import Bool, Int, Program, Rung, out
from pyrung.circuitpy import ModbusServerConfig, P1AM, generate_circuitpy
from pyrung.click import TagMap, c, ds

# Hardware
hw = P1AM()
inputs  = hw.slot(1, "P1-08SIM")
outputs = hw.slot(2, "P1-08TRS")

Button   = inputs[1]
Light    = outputs[1]
Setpoint = Int("Setpoint")

# Logic
with Program() as logic:
    with Rung(Button):
        out(Light)

# Map to Click addresses for Modbus visibility
mapping = TagMap({
    Setpoint: ds[1],
    Light:    c[1],
})

# Generate with Modbus server
source = generate_circuitpy(
    logic, hw,
    target_scan_ms=10.0,
    watchdog_ms=500,
    modbus_server=ModbusServerConfig(ip="192.168.1.200"),
    tag_map=mapping,
)

The generated code starts a Modbus TCP listener on the configured IP and port. Any Modbus client reading DS1 gets the current value of Setpoint; writing DS1 updates it. Reading coil C1 returns the state of Light. The register layout is identical to a real Click PLC — same Modbus addresses, same data encoding.

Field Type Default Notes
ip str Static IPv4 for the P1AM-ETH shield
subnet str "255.255.255.0"
gateway str "192.168.1.1"
dns str "0.0.0.0"
port int 502 1–65535
max_clients int 2 1–7 concurrent connections (W5500 has 8 sockets, 1 reserved for listener)

Supported function codes: FC 1 (read coils), FC 2 (read discrete inputs), FC 3 (read holding registers), FC 4 (read input registers), FC 5 (write single coil), FC 6 (write single register), FC 15 (write multiple coils), FC 16 (write multiple registers).

Client — send and receive

from pyrung import Bool, Int, Block, Program, Rung, TagType
from pyrung.circuitpy import ModbusClientConfig, P1AM, generate_circuitpy
from pyrung.click import ModbusTcpTarget, TagMap, send, receive

hw = P1AM()
hw.slot(1, "P1-08SIM")

Enable        = Bool("Enable")
LocalSetpoint = Int("LocalSetpoint")
RemoteWords   = Block("RemoteWords", TagType.INT, 1, 4)

CommSending   = Bool("CommSending")
CommReceiving = Bool("CommReceiving")
CommSuccess   = Bool("CommSuccess")
CommError     = Bool("CommError")
CommEx        = Int("CommEx")

with Program() as logic:
    with Rung(Enable):
        send(
            target="plc1",
            remote_start="DS1",
            source=LocalSetpoint,
            sending=CommSending,
            success=CommSuccess,
            error=CommError,
            exception_response=CommEx,
        )

    with Rung(Enable):
        receive(
            target="plc1",
            remote_start="DS100",
            dest=RemoteWords.select(1, 4),
            receiving=CommReceiving,
            success=CommSuccess,
            error=CommError,
            exception_response=CommEx,
        )

source = generate_circuitpy(
    logic, hw,
    target_scan_ms=10.0,
    modbus_client=ModbusClientConfig(
        targets=(ModbusTcpTarget(name="plc1", ip="192.168.1.20"),)
    ),
    tag_map=TagMap(),
)

send writes local tag values to a remote Click address. receive reads remote Click addresses into local tags. The target string must match a ModbusTcpTarget.name. Remote addresses use Click address format (DS1, C1, X001, etc.).

Raw Modbus addresses

When the remote device isn't a Click PLC, use ModbusAddress instead of a Click address string. This gives direct control over the register address and register type.

from pyrung.core.instruction.send_receive import ModbusAddress, RegisterType

vfd = ModbusTcpTarget(name="vfd", ip="192.168.1.30")

with Program() as logic:
    # Read a 32-bit speed value from holding registers 0x200–0x201, word-swapped
    with Rung(Enable):
        receive(
            target="vfd",
            remote_start=ModbusAddress(0x200, RegisterType.HOLDING),
            dest=Speed,
            receiving=CommReceiving,
            success=CommSuccess,
            error=CommError,
            exception_response=CommEx,
            word_swap=True,
        )

    # Write a setpoint to a single holding register at 0x100
    with Rung(Enable):
        send(
            target="vfd",
            remote_start=ModbusAddress(0x100),
            source=Setpoint,
            sending=CommSending,
            success=CommSuccess,
            error=CommError,
            exception_response=CommEx,
        )

ModbusAddress accepts MODBUS 984 addresses (e.g. 400001 for holding, 300001 for input) or raw register offsets (0–0xFFFE). Hex strings with an h suffix (e.g. "0h") are also supported — these require an explicit register_type since the offset alone is ambiguous.

Field Type Default Notes
address int or str 984-style int (400001), raw int (0–0xFFFE), or hex str ("0h")
register_type RegisterType HOLDING Inferred for 984 addresses; required for hex

The codegen maps register_type to the correct Modbus function code:

Type Send Receive
HOLDING FC 6 (single) / FC 16 (multiple) FC 3
INPUT FC 4
COIL FC 5 (single) / FC 15 (multiple) FC 1
DISCRETE_INPUT FC 2

word_swap on send()/receive() controls how 32-bit values (DINT, REAL) are split across register pairs. False (default) = high word first (big-endian, common in VFDs and power meters). True = low word first.

RTU (serial) targets are not yet supported for CircuitPython code generation.

Field Type Default Notes
name str Unique identifier, referenced by target= in send/receive
ip str Remote PLC address
port int 502
device_id int 1 Modbus unit ID (0–255)
timeout_ms int 1000 Per-transaction timeout

Unlike the Click dialect's threaded send/receive, the CircuitPython versions generate a non-blocking state machine. Each transaction advances one step per scan (connect → send request → wait for response → apply result). The scan loop is never blocked. Status tags (sending/receiving, success, error, exception_response) update as the transaction progresses. When the rung condition goes false, status tags reset to defaults.

TagMap and mapped_tag_scope

tag_map is required when modbus_server or modbus_client is set. It determines which tags are visible over Modbus — the TagMap maps semantic tags to Click hardware addresses, and the codegen uses those addresses as Modbus register addresses.

mapped_tag_scope controls how many TagMap entries get backing variables in the generated code:

Value Behavior
"referenced_only" (default) Tags used in logic and tags with non-default initial values
"all_mapped" Every entry in the TagMap gets a backing variable

The default avoids allocating RAM for tags that no rung references and start with type-default values. Use "all_mapped" when an HMI or SCADA system needs to write values via Modbus even though no ladder rung touches them.

Scan cycle with Modbus

  1. Read physical inputs
  2. Execute rungs
  3. Write physical outputs
  4. Service Modbus server
  5. Service Modbus client
  6. Edge snapshots, watchdog pet, scan sleep

The server and client service calls run unconditionally — including in STOP mode. This matches Click behavior: an HMI can still read tag state and see sys.mode_run as False while the PLC is stopped.

Both server and client

The P1AM-200 can be both server and client simultaneously. A single Ethernet setup is shared.

source = generate_circuitpy(
    logic, hw,
    target_scan_ms=10.0,
    modbus_server=ModbusServerConfig(ip="192.168.1.200"),
    modbus_client=ModbusClientConfig(
        targets=(ModbusTcpTarget(name="plc1", ip="192.168.1.20"),)
    ),
    tag_map=mapping,
)