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
- Read physical inputs
- Execute rungs
- Write physical outputs
- Service Modbus server
- Service Modbus client
- 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.