CircuitPython Dialect
pyrung.circuitpy generates a self-contained CircuitPython scan loop from the same program you already tested in simulation. The same pyrung logic runs in Python and on real hardware — write once, simulate, deploy.
It targets the ProductivityOpen P1AM-200: tinkerer-friendly PLC hardware with a CircuitPython runtime, and currently the only PLC-class controller where this kind of codegen is this straightforward. The hardware model (35 modules), code generator, and validation are fully implemented and tested.
Installation
from pyrung import Bool, Int, Real, PLC, Program, Rung, out, copy, rise
from pyrung.circuitpy import P1AM, RunStopConfig, board, generate_circuitpy, validate_circuitpy_program
Hardware setup — P1AM
The ProductivityOpen P1AM-200 is a base unit with up to 15 slots for Productivity1000 I/O modules. Configure hardware with the P1AM class:
hw = P1AM()
inputs = hw.slot(1, "P1-08SIM") # 8-ch discrete input → InputBlock(Bool)
outputs = hw.slot(2, "P1-08TRS") # 8-ch discrete output → OutputBlock(Bool)
analog = hw.slot(3, "P1-04ADL-1") # 4-ch analog input → InputBlock(Int)
Each hw.slot() call returns:
InputBlockfor input-only modulesOutputBlockfor output-only modulestuple[InputBlock, OutputBlock]for combo modules (e.g.P1-16CDR)
Slots must be numbered 1–15 and contiguous from 1 (matching physical wiring order). Use the optional name keyword to override the default "Slot{N}" prefix:
Supported modules
The built-in MODULE_CATALOG includes 35 modules from the Productivity1000 series across six categories:
| Category | Count | Examples |
|---|---|---|
| Discrete input | 7 | P1-08SIM, P1-16ND3, P1-08NA, P1-08NE3 |
| Discrete output | 9 | P1-08TRS, P1-16TR, P1-04TRS, P1-08TA |
| Combo discrete | 3 | P1-16CDR, P1-15CDD1, P1-15CDD2 |
| Analog input | 7 | P1-04AD, P1-04ADL-1, P1-08ADL-1 |
| Analog output | 4 | P1-04DAL-1, P1-04DAL-2, P1-08DAL-1 |
| Temperature input | 3 | P1-04RTD, P1-04THM, P1-04NTC |
| Combo analog | 2 | P1-4ADL2DAL-1, P1-4ADL2DAL-2 |
Type mapping: discrete → Bool, analog → Int, temperature → Real.
Excluded modules (v2)
P1-04PWM (PWM) and P1-02HSC (high-speed counter) require a multi-tag channel model and are deferred to v2.
Writing a CircuitPython program
Programs use the same DSL as any other pyrung dialect — only the hardware setup and export step are dialect-specific.
from pyrung import Bool, Int, PLC, Program, Rung, out, copy, rise
from pyrung.circuitpy import P1AM, write_circuitpy
# 1. Configure hardware
hw = P1AM()
inputs = hw.slot(1, "P1-08SIM")
outputs = hw.slot(2, "P1-08TRS")
Button = inputs[1]
Light = outputs[1]
Counter = Int("Counter")
# 2. Write logic — identical to any other pyrung program
with Program() as logic:
with Rung(Button):
out(Light)
# 3. Simulate
with PLC(logic, dt=0.1) as plc:
Button.value = True
plc.step()
assert Light.value is True
# 4. Generate code.py — copy to CIRCUITPY drive
write_circuitpy(logic, hw, target_scan_ms=10.0, watchdog_ms=500, output_dir=".")
Code generation
from pyrung.circuitpy import write_circuitpy
# Write code.py to a directory — the common case
write_circuitpy(logic, hw, target_scan_ms=10.0, output_dir=".")
write_circuitpy generates and writes code.py to output_dir. It accepts the same parameters as generate_circuitpy plus output_dir.
For programmatic use (no file I/O):
from pyrung.circuitpy import generate_circuitpy
result = generate_circuitpy(logic, hw, target_scan_ms=10.0)
result.code # code.py content (str)
result.runtime # pyrung_rt.py content (str) — for maintainer use
| Parameter | Type | Description |
|---|---|---|
program |
Program |
Ladder logic program |
hw |
P1AM |
Hardware configuration |
target_scan_ms |
float |
Target scan cycle time in milliseconds (must be > 0) |
watchdog_ms |
int \| None |
Hardware watchdog timeout in ms, or None to disable |
runstop |
RunStopConfig \| None |
Optional board-switch RUN/STOP mapping with debounce |
modbus_server |
ModbusServerConfig \| None |
Modbus TCP server config; see Modbus TCP |
modbus_client |
ModbusClientConfig \| None |
Modbus TCP client config; see Modbus TCP |
tag_map |
TagMap \| None |
Click address mapping for Modbus-visible tags; required with server/client |
mapped_tag_scope |
MappedTagScope |
"referenced_only" (default) or "all_mapped" |
The generator runs strict validation internally and checks the generated source for syntax errors before returning.
Generated output
Code generation produces two files:
code.py— your program. Tags, ladder logic, I/O, scan loop. Regenerated every time you change your logic.pyrung_rt.mpy— the pyrung runtime library (pre-compiled). Helper functions, Modbus TCP server/client state machine. Same for every project — install once.
code.py imports from pyrung_rt at runtime. The .mpy format loads faster and uses less memory than .py on CircuitPython.
code.py structure
- Imports —
time,json,board,busio,P1AM,sdcardio,storage,microcontroller,pyrung_rt - Configuration —
TARGET_SCAN_MS,WATCHDOG_MS, slot module list, retentive schema hash - Hardware bootstrap —
P1AM.Base(),rollCall(), optional watchdog init - Tag declarations — one variable per scalar tag, one list per block
- Memory buffers — edge-detection state, scan timing
- SD mount / retentive load/save — generated when retentive tags exist
- Modbus address mapping — wires tags to the runtime's Modbus server/client
- Ladder logic — compiled rungs
while Truescan loop — reads inputs, executes rungs, writes outputs, paces to target scan time
Retentive tag persistence
Tags marked retentive=True are automatically persisted to an SD card:
- Storage path:
/sd/memory.json(atomic writes via temp file) - Schema hash: SHA-256 of tag names and types. On load, a schema mismatch (e.g. after a firmware change) skips the stale file and starts from defaults.
- NVM dirty flag:
microcontroller.nvm[0]is set to1before writing and cleared to0after. If the controller restarts mid-write, the dirty flag prevents loading a corrupt file.
SD system command bits
The generated runtime supports system SD command bits:
board.save_memory_cmdtriggerssave_memory()from ladder logic.system.storage.sd.delete_all_cmdremoves only retentive files (/sd/memory.jsonand/sd/_memory.tmp).system.storage.sd.eject_cmdcallsstorage.umount("/sd")and keeps SD unavailable until reboot/remount.
Commands are auto-cleared after servicing and pulse system.storage.sd.write_status for that scan. Command-operation failures set system.storage.sd.error = True and system.storage.sd.error_code = 3.
Example ladder trigger:
Onboard board model
pyrung.circuitpy exposes first-class onboard peripheral tags:
board.switch(InputTag, BOOL)board.led(OutputTag, BOOL)board.neopixel.r/g/b(OutputTag, INT channels, clamped 0..255 in generated writes)board.save_memory_cmd(OutputTag, BOOL save trigger)
These board tags can be used even when no slots are configured (zero-slot code generation).
Optional RUN/STOP mapping
Pass RunStopConfig(...) to generate_circuitpy() to map board.switch to runtime RUN/STOP mode:
- debounced switch sampling (
debounce_ms, default30) run_when_highpolarity control- optional exposure of
sys.mode_runandsys.cmd_mode_stop(expose_mode_tags=True) - STOP skips rung execution and forces physical outputs off each scan
- STOP->RUN resets non-retentive runtime state while preserving retentive values
Watchdog
When watchdog_ms is set, the generated code calls base.config_watchdog() and base.start_watchdog() at boot, then base.pet_watchdog() each scan. If the scan loop stalls longer than the timeout, the P1AM hardware resets the controller.
Scan timing and overrun detection
The scan loop paces itself to target_scan_ms using time.monotonic(). If a scan takes longer than the target, the overrun is counted and optionally printed (controlled by PRINT_SCAN_OVERRUNS in the generated code).
Modbus TCP
The generated code can include a Modbus TCP server, client, or both via the P1AM-ETH shield. Configuration and usage:
Validation
report = validate_circuitpy_program(program, hw, mode="warn")
print(report.summary())
for finding in report.errors + report.warnings + report.hints:
print(f" {finding.severity}: [{finding.code}] {finding.message}")
| Parameter | Type | Default | Description |
|---|---|---|---|
program |
Program |
— | Program to validate |
hw |
P1AM \| None |
None |
Hardware config for I/O traceability checks |
mode |
"warn" \| "strict" |
"warn" |
"warn" emits hints; "strict" enforces blocking findings as errors |
Finding codes
| Code | Trigger | Description |
|---|---|---|
CPY_FUNCTION_CALL_VERIFY |
FunctionCallInstruction in program |
Callable will be embedded via inspect.getsource() — verify it uses only CircuitPython-compatible APIs |
CPY_IO_BLOCK_UNTRACKED |
I/O tag not traceable to a P1AM slot |
Tag was created outside hw.slot() — it won't be wired to physical I/O in generated code |
CPY_TIMER_RESOLUTION |
on_delay / off_delay with Tms timing |
Millisecond timer accuracy depends on scan time; effective resolution is one scan |
In "warn" mode these produce hints. In "strict" mode, CPY_IO_BLOCK_UNTRACKED is an error, while CPY_FUNCTION_CALL_VERIFY and CPY_TIMER_RESOLUTION remain advisory hints. generate_circuitpy() runs strict validation internally and blocks on validation errors.
Deploying to hardware
One-time board setup
- Install CircuitPython on the P1AM-200
- Install the CircuitPython P1AM library and its dependencies into
CIRCUITPY/lib/ - Download
pyrung_rt.mpyfrom the pyrung releases page and copy it toCIRCUITPY/lib/ - Insert a FAT-formatted SD card for retentive tag storage (if using retentive tags)
Iterate
Copy the generated code.py to the P1AM-200's CIRCUITPY drive. It runs automatically on boot.
If your program uses FunctionCallInstruction, the callable's source is embedded verbatim. Ensure it only uses CircuitPython-compatible modules and APIs.
CircuitPython constraints
The P1AM-200 runs CircuitPython, which imposes limits beyond what the pyrung simulator allows:
- No hardware interrupts. All I/O is polled each scan. Fast external signals can be missed between scans — choose
target_scan_msaccordingly. - No TLS. Modbus TCP and any network traffic run unencrypted. Keep the P1AM-200 on a trusted, isolated network.
- Single-threaded. The scan loop is cooperative. Long-running
FunctionCallInstructioncallables block the entire scan (and may trip the watchdog). - Limited memory. CircuitPython has a small heap. Programs with many tags or large blocks may hit memory limits — test on hardware early.
External resources
- P1AM-200 documentation — hardware specs, pinout, getting started
- CircuitPython P1AM library — the runtime library used by generated code
- Productivity1000 I/O module docs — per-module wiring diagrams and specs
- P1AM-200 on AutomationDirect — ordering and datasheets
- CircuitPython documentation — language reference for the target runtime