CircuitPython Dialect
pyrung.circuitpy adds a P1AM-200 hardware model, module catalog, program validation, and CircuitPython code generation on top of the hardware-agnostic core.
Aspirational PoC. The CircuitPython dialect demonstrates the "write once, simulate and deploy" thesis end-to-end: the same pyrung program runs in Python simulation and generates a self-contained CircuitPython
while Truescan loop that runs directly on the hardware. The hardware model (35 modules) and code generator are fully implemented and tested. The P1AM-200 is the natural target: tinkerer-friendly PLC hardware with a CircuitPython runtime, and currently the only PLC-class controller where this kind of codegen is this straightforward.
Installation
from pyrung import Bool, Int, Real, PLCRunner, Program, Rung, TimeMode, 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, PLCRunner, Program, Rung, TimeMode, out, copy, rise
from pyrung.circuitpy import P1AM, generate_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
runner = PLCRunner(logic)
runner.set_time_mode(TimeMode.FIXED_STEP, dt=0.1)
with runner.active():
Button.value = True
runner.step()
assert Light.value is True
# 4. Generate deployable CircuitPython code
source = generate_circuitpy(logic, hw, target_scan_ms=10.0, watchdog_ms=500)
Code generation
source = generate_circuitpy(
program,
hw,
target_scan_ms=10.0,
watchdog_ms=500,
runstop=RunStopConfig(),
)
| 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 |
Returns a complete, self-contained CircuitPython source file as a string. The generator runs strict validation internally and checks the generated source for syntax errors before returning.
Generated file structure
The output is a single .py file organized as:
- Imports —
time,json,board,busio,P1AM,sdcardio,storage,microcontroller - 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
- Helper functions — rising edge, type conversions, math helpers
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).
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
The generated code uses the CircuitPython P1AM library. Make sure the library is installed on your P1AM-200 before deploying (see P1AM-200 getting started guide).
- Call
generate_circuitpy()to produce the source string - Write it to a file (e.g.
code.py) - Copy
code.pyto the P1AM-200's CIRCUITPY drive — it runs automatically on boot - Insert an SD card for retentive tag storage (FAT-formatted)
If your program uses FunctionCallInstruction, the callable's source is embedded verbatim. Ensure it only uses CircuitPython-compatible modules and APIs.
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