Click PLC Dialect
pyrung.click adds Click-PLC-specific blocks, type aliases, address mapping, nickname file I/O, validation, and a soft-PLC adapter on top of the hardware-agnostic core.
Installation
pyrung.click uses pyclickplc for address metadata, nickname CSV I/O, and soft-PLC server/client integration.
Imports
from pyrung import Bool, Int, PLC, Program, Rung, copy, latch, reset, rise
from pyrung.click import x, y, c, ds, TagMap
Workflow: write first, validate later
pyrung is intentionally permissive. Write logic with semantic tag names and native Python expressions — no address mapping required — and simulate freely. Hardware constraints are opt-in.
The natural progression:
- Write — define semantic tags (
StartButton,MotorRunning,Speed) and express logic in Python - Simulate — run tests with a fixed
dt; set inputs, assert outputs, iterate - Map — create a
TagMaplinking semantic tags to Click hardware addresses - Validate —
mapping.validate(logic, mode="warn")surfaces Click-incompatible patterns - Iterate — fix findings, tighten to
mode="strict"when the program is clean
The validator tells you exactly what Click can't do — inline expressions, unsupported pointer modes, type mismatches — before you discover it at deploy time.
Pre-built blocks
pyrung.click exports pre-built blocks for every Click memory bank:
| Variable | Bank | Type | Block kind |
|---|---|---|---|
x |
X (inputs) | BOOL | InputBlock |
y |
Y (outputs) | BOOL | OutputBlock |
c |
C (bit memory) | BOOL | Block |
ds |
DS (int memory) | INT | Block |
dd |
DD (double int) | DINT | Block |
dh |
DH (hex memory) | WORD | Block |
df |
DF (float memory) | REAL | Block |
t |
T (timer done) | BOOL | Block |
td |
TD (timer acc) | INT | Block |
ct |
CT (counter done) | BOOL | Block |
ctd |
CTD (counter acc) | DINT | Block |
sc |
SC (system control) | BOOL | Block |
sd |
SD (system data) | INT | Block |
txt |
TXT (text memory) | CHAR | Block |
xd |
XD (word image) | WORD | InputBlock |
yd |
YD (word image) | WORD | OutputBlock |
Addresses use canonical Click display names:
Sparse banks
X and Y are sparse banks with non-contiguous valid addresses. .select() filters to valid addresses automatically:
Per-slot configuration
Pre-built blocks support per-slot runtime policy for retention, default values, and naming. Configure before first access to a slot:
ds.slot(10, name="RecipeStep")
ds.slot(200, retentive=True, default=123)
td.slot(1, 5, retentive=False, default=0)
If a slot is already materialized (block[n] accessed), later configuration for that slot raises ValueError.
Type aliases
Click-style constructor aliases as alternatives to IEC names:
| Click alias | IEC equivalent |
|---|---|
Bit |
Bool |
Int2 |
Dint |
Float |
Real |
Hex |
Word |
Txt |
Char |
DSL naming philosophy
This DSL follows Click PLC instruction naming as closely as possible, departing only when a Python conflict exists and the replacement name is genuinely better in a Python-hosted context.
- Keep the Click name when it's a clear action verb with no conflict:
out,reset,fill,copy,blockcopy. - Use a domain synonym when Click's name shadows a Python builtin or standard library module:
set→latch,math→calc. Both are well-understood PLC terminology. - Use clarified intent when Python's execution model changes the semantics:
return→return_early. In Click, every subroutine needs an explicitRET. In this DSL, normal subroutine completion is implicit, so the only use is early exit — and the name should say so.
| Click instruction | pyrung DSL | Reason |
|---|---|---|
SET |
latch |
Shadows Python builtin set |
MATH |
calc |
Shadows Python stdlib math |
RET |
return_early |
Normal return is implicit; only early exit needs a call |
The CSV ladder export uses Click-facing token names: calc emits as math(...), return_early as return(), and forloop as for(...). See the laddercodec CSV format guide for the full token grammar.
Writing a Click program
from pyrung import Bool, Real, PLC, Program, Rung, copy, latch, reset, rise
from pyrung.click import x, y, c, ds, df, TagMap
# Define semantic tags (hardware-agnostic)
StartButton = Bool("StartButton")
StopButton = Bool("StopButton")
MotorRunning = Bool("MotorRunning")
RawSpeed = Real("RawSpeed")
Speed = Real("Speed")
# Write logic using semantic names
with Program() as logic:
with Rung(rise(StartButton)):
latch(MotorRunning)
with Rung(rise(StopButton)):
reset(MotorRunning)
with Rung(MotorRunning):
copy(RawSpeed, Speed)
# Simulate — no mapping needed
with PLC(logic, dt=0.1) as plc:
StartButton.value = True
plc.step()
TagMap — mapping to hardware
TagMap links semantic tags and blocks to Click hardware addresses. Mapping is separate from logic — write and simulate first, map when ready.
Dict constructor
mapping = TagMap({
StartButton: x[1], # BOOL → X001
StopButton: x[2], # BOOL → X002
MotorRunning: y[1], # BOOL → Y001
RawSpeed: df[1], # REAL → DF1 (analog input)
Speed: df[11], # REAL → DF11
})
Method-call syntax
Mapping a block to a hardware range
Alarms = Block("Alarms", TagType.BOOL, 1, 100)
mapping = TagMap({
Alarms: c.select(101, 200), # Alarms[1..100] → C101..C200
})
Timer/Counter mapping
Timer and Counter are built-in UDTs. Map them to Click hardware banks explicitly, just like any other block:
OvenTimer = Timer.clone("OvenTimer")
mapping = TagMap([
OvenTimer.Done.map_to(t[1]),
OvenTimer.Acc.map_to(td[1]),
])
Codegen from nicknames
When importing ladder CSV with nicknames, a nickname on a T or CT address produces a named clone:
# Without nickname → clone named after the operand
T1 = Timer.clone("T1")
on_delay(T1, preset=100)
# With nickname {"T1": "OvenTimer"} → clone named after the nickname
OvenTimer = Timer.clone("OvenTimer")
on_delay(OvenTimer, preset=100)
The T (done-bit) nickname drives the name — any nickname on the matching TD/CTD address is silently overridden. This keeps .Done and .Acc fields under a single consistent prefix.
If the nickname already ends with _Done or _Acc, the suffix is stripped automatically — "OvenTimer_Done" becomes Timer.clone("OvenTimer").
Condition references resolve through the clone. A rung conditioned on T1 renders as OvenTimer.Done:
Type validation at map time
TagMap validates that logical and hardware data types match:
From nickname file
Load an existing Click nickname CSV:
The importer reconstructs blocks, structures, and standalone tags from CSV comment markers and nickname patterns. Standalone nicknames become individual Tag objects. Non-marker address-comment text is preserved on standalone tags and block slots for CSV round-trip export.
For strict grouping validation, pass mode="strict" — this fails fast on structure grouping mismatches instead of falling back to plain blocks with a warning.
Imported structure metadata is available via mapping.structures and mapping.structure_by_name("Base").
CSV marker format
The comment field on CSV rows carries block and structure boundaries. Three marker types:
| Marker | Example | Meaning |
|---|---|---|
| Opening | <Alarms:block> |
Start of a semantic plain block |
| Closing | </Alarms:block> |
End of a semantic plain block |
| Self-closing | <Config.timeout:udt /> |
Single-row semantic marker |
Bare tags are grouping-only comments: <Alarms>, </Alarms>, <Base.field>, and <Base.field /> do not reconstruct pyrung semantics. They simply group rows visually, and any inner nicknames import as ordinary standalone tags.
Plain blocks use explicit :block markers: <Alarms:block> / </Alarms:block>. If the logical start differs from the inferred default, export/import uses <Alarms:block(n)> or <Alarms:block(start=n)>. If a boundary row has a blank nickname, default retentive/default value, and its comment is only the block tag, pyrung treats that row as boundary metadata rather than a slot rename/config override.
Named arrays use :named_array markers. Count and stride are optional — the importer infers them from the row span between open/close tags:
<Task:named_array> count=1, stride from row count
<Task:named_array(2)> count=2, stride = rows / count
<Task:named_array(2,3)> count=2, stride=3 (fully explicit)
When both count and stride are given, the row span must equal count × stride. When stride is omitted, the row count must be divisible by count.
Nickname patterns. For count > 1, nicknames must follow {Base}{instance}_{field} with 1-based instance numbers. The instance is derived from position: position // stride + 1. Field names are the suffix after the prefix strip (Channel1_Id → field Id).
For count = 1, nicknames default to the compact form {Base}_{field} (no instance number). If the CSV already uses numbered names like Task1_Call, the importer detects this and sets always_number=True automatically. To force numbered names explicitly, add ,always_number to the marker:
The always_number flag only matters for singletons — count > 1 is always numbered regardless.
Instance rules. Instance 1 defines the field template — all its fields must be explicitly named. Instance 2+ fields must match instance 1's pattern (correct field name and instance number). Unnamed slots in instance 2+ are fine (silently skipped). A field name in instance 2+ that wasn't defined in instance 1 is an error.
Example — Channel with 2 instances, 3 fields, no gaps (stride=3):
| Address | Nickname | Comment |
|---|---|---|
| DS101 | Channel1_Id |
<Channel:named_array(2,3)> |
| DS102 | Channel1_Val |
|
| DS103 | Channel1_Name |
|
| DS104 | Channel2_Id |
|
| DS105 | Channel2_Val |
|
| DS106 | Channel2_Name |
</Channel:named_array(2,3)> |
Singleton with compact names (count=1):
| Address | Nickname | Comment |
|---|---|---|
| DS501 | Task_Call |
<Task:named_array(1,2)> |
| DS502 | Task_Done |
</Task:named_array(1,2)> |
If stride exceeds the field count, the extra slots are gaps (empty nicknames):
| Address | Nickname | Comment |
|---|---|---|
| DS101 | Sensor1_Raw |
<Sensor:named_array(2,3)> |
| DS102 | Sensor1_Scaled |
|
| DS103 | (gap) | |
| DS104 | Sensor2_Raw |
|
| DS105 | Sensor2_Scaled |
|
| DS106 | </Sensor:named_array(2,3)> |
Click codegen can round-trip aligned whole-instance spans back into pyrung as Name.instance(...) or Name.instance_select(...) instead of raw bank ranges. This works for both dense and sparse layouts:
blockcopy(RecipeProfile.instance(2), WorkingRecipe.select(1, 3))
fill(0, RecipeProfile.instance_select(1, 2))
UDTs use explicit :udt markers per field and memory bank. Each attribute range is a separate marker:
The importer collects all Base.Field:udt ranges that share the same base name and assembles them into a single @udt. Field attribute ranges must have matching hardware span lengths across all attributes.
Bare dotted tags such as <Motor.Speed> are grouping-only and do not reconstruct a UDT.
Nesting is not supported — a UDT field cannot itself be a named array (e.g. Sts.Recipes:named_array(20,50) won't parse). Flatten the name instead: StsRecipes:named_array(20,50).
Conflict rules. The same base name cannot be used across different marker kinds. These combinations are all errors:
- Same name as both
:named_arrayand.attr:udt - Same name as both
:blockand:named_array - Same name as both
:blockand.attr:udt - Duplicate
:named_arrayor:blockmarkers for the same name
To nickname file
Export to Click nickname CSV for import into CLICK Programming Software:
Mapped tags and blocks emit rows with canonical logical names, initial values, retentive flags, and preserved address comments. If a row needs both a block marker and user comment text, both are emitted in the same CSV comment field. Unmapped tags are omitted.
Validation
After mapping, validate your program against Click hardware restrictions:
report = mapping.validate(logic, mode="warn")
print(report.summary())
for finding in report.findings:
print(f" {finding.level}: {finding.message}")
Common findings:
| Issue | pyrung allows | Click requires |
|---|---|---|
Pointer in copy source |
Any block, arithmetic | DS only, no arithmetic |
| Inline expression in condition | (A + B) > 100 |
Must use calc() first |
Timer preset limits
Click timer accumulators are 16-bit signed INT (max 32,767). A literal preset exceeding this range silently clamps at runtime. The validator reports CLK_TIMER_PRESET_OVERFLOW for out-of-range presets — use a larger time unit instead.
| Unit | Max preset | Max duration |
|---|---|---|
Tms |
32,767 | 32.7 seconds |
Ts |
32,767 | 9.1 hours |
Tm |
32,767 | 22.7 days |
Th |
32,767 | 3.7 years |
Td |
32,767 | 89 years |
# Wrong — clamps silently to 32.7 seconds
on_delay(MyTimer, preset=60000)
# Right — use seconds
on_delay(MyTimer, preset=60, unit="sec")
Findings are hints by default (mode="warn"). Use mode="strict" to treat hints as errors.
Ladder CSV export
pyrung_to_ladder(program, tag_map) emits deterministic Click ladder CSV row matrices via LadderBundle.
from pyrung.click import pyrung_to_ladder
bundle = pyrung_to_ladder(logic, mapping)
bundle.main_rows # inspect rows in-memory
bundle.write("./output") # write main.csv + subroutines/*.csv to disk
For the consumer-facing CSV decode contract (files, row semantics, token formats, branch wiring, and supported tokens), see the laddercodec CSV format guide.
To convert ladder CSV back into pyrung Python source, see Click Python Codegen.
Empty and comment-only rungs
Empty rungs survive the round-trip. A with Rung(): pass in pyrung exports as NOP in the Click CSV AF column and imports back as pass.
For Click programs that want to be explicit, pyrung.click also provides nop():
from pyrung.click import nop
comment("Section header")
with Rung():
nop() # one per rung, must be the sole instruction
Both forms produce identical CSV output.
Loading PLC state
Use Click Programming Software's Data > Read Data from PLC to dump the live state of a Click PLC to CSV, then load that snapshot into a pyrung runner so it starts right where the PLC was.
from pyclickplc import read_plc_data
from pyrung.core import PLC, SystemState
data = read_plc_data("data.csv", skip_default=True)
tags = mapping.tags_from_plc_data(data)
runner = PLC(logic, initial_state=SystemState().with_tags(tags))
read_plc_data (from pyclickplc) parses the CSV and returns {hardware_address: value}. tags_from_plc_data translates the hardware keys to logical tag names using the TagMap, silently skipping any addresses that aren't mapped. The result is ready for SystemState.with_tags() or runner.patch().
skip_default=True omits zero/false/empty values — useful since PLC dumps are exhaustive and most addresses are at their default.
You can also inject a PLC snapshot mid-run:
data = read_plc_data("data.csv", skip_default=True)
runner.patch(mapping.tags_from_plc_data(data))
runner.step() # applied at the start of this scan
ClickDataProvider — soft PLC
ClickDataProvider implements the pyclickplc DataProvider protocol, bridging pyrung's SystemState to a Modbus TCP server. This lets pyrung act as a soft PLC accessible from Click Programming Software or any Modbus client.
from pyrung.click import ClickDataProvider
from pyclickplc.server import ClickServer
provider = ClickDataProvider(runner, tag_map=mapping)
server = ClickServer(provider, port=502)
Reads return the current committed state. Writes queue a runner.patch() for the next scan.
If another device on the LAN can't reach the soft PLC, check the Windows Firewall: both the TCP port (default 502) and the Python interpreter need to be allowed through.
Word-image (XD / YD) addressing
XD*reads compute bit-image words from current X bit state.YD*reads compute bit-image words from current Y bit state.YD*writes fan out to the corresponding Y bits.XD*writes are rejected (read-only).
Communication instructions
send and receive implement Modbus communication with remote devices. Two addressing modes are supported:
Click addresses (Click-to-Click)
Use a Click address string for remote_start when talking to another Click PLC:
from pyrung.click import ModbusTcpTarget, send, receive
plc = ModbusTcpTarget("plc1", "192.168.1.20")
send(
target=plc,
remote_start="DS1",
source=LocalSetpoint,
sending=CommSending,
success=CommSuccess,
error=CommError,
exception_response=CommEx,
)
receive(
target=plc,
remote_start="DS1",
dest=LocalWords.select(1, 4),
receiving=CommReceiving,
success=CommSuccess,
error=CommError,
exception_response=CommEx,
)
Click handles word swap and character order natively — no configuration needed on the pyrung side.
Raw Modbus addresses (any device)
Use ModbusAddress for remote_start when talking to non-Click Modbus devices (VFDs, meters, sensors, etc.):
from pyrung import ModbusAddress, ModbusTcpTarget, ModbusRtuTarget, RegisterType, send, receive
vfd = ModbusTcpTarget("vfd", "192.168.1.50")
send(
target=vfd,
remote_start=ModbusAddress(400001),
source=SpeedSetpoint,
sending=VfdSending,
success=VfdSuccess,
error=VfdError,
exception_response=VfdEx,
)
meter = ModbusRtuTarget("meter", "/dev/ttyUSB0", device_id=3, baudrate=19200)
receive(
target=meter,
remote_start=ModbusAddress(300001),
dest=MeterReading,
receiving=MeterReceiving,
success=MeterSuccess,
error=MeterError,
exception_response=MeterEx,
word_swap=True,
)
ModbusAddress accepts MODBUS 984 addresses (e.g. 400001 for holding, 300001 for input, 100001 for discrete input) or hex strings with an h suffix (e.g. "0h", "FFFEh"). For 984 addresses, the register type is inferred from the prefix. Hex addresses need an explicit RegisterType since the offset alone is ambiguous.
word_swap controls how 32-bit values (DINT, REAL) are packed across register pairs. False (default) = high word first, True = low word first. Only relevant for 32-bit Click types (DD, DF, etc.).
RegisterType selects the Modbus function code: HOLDING (FC 3/6/16, default), INPUT (FC 4, read-only), COIL (FC 1/5/15), DISCRETE_INPUT (FC 2, read-only). Sending to INPUT or DISCRETE_INPUT raises ValueError.
Target types
| Type | Transport | Live I/O | Codegen |
|---|---|---|---|
ModbusTcpTarget |
Ethernet | Yes (pymodbus for raw, pyclickplc for Click addresses) | Yes |
ModbusRtuTarget |
Serial | Yes (pymodbus) | Not yet |
str (name only) |
— | No (inert) | Yes (resolved via ModbusClientConfig) |
When target is a ModbusTcpTarget or ModbusRtuTarget, communication runs asynchronously in a background worker pool — the scan loop stays synchronous. When target is a plain string, the instruction is inert during simulation and exists only for CircuitPython code generation.