Skip to content

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

pip install pyrung
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:

  • InputBlock for input-only modules
  • OutputBlock for output-only modules
  • tuple[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:

hw.slot(1, "P1-08SIM", name="Sensors")   # tags named Sensors.1 .. Sensors.8

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

  1. Importstime, json, board, busio, P1AM, sdcardio, storage, microcontroller, pyrung_rt
  2. ConfigurationTARGET_SCAN_MS, WATCHDOG_MS, slot module list, retentive schema hash
  3. Hardware bootstrapP1AM.Base(), rollCall(), optional watchdog init
  4. Tag declarations — one variable per scalar tag, one list per block
  5. Memory buffers — edge-detection state, scan timing
  6. SD mount / retentive load/save — generated when retentive tags exist
  7. Modbus address mapping — wires tags to the runtime's Modbus server/client
  8. Ladder logic — compiled rungs
  9. while True scan 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 to 1 before writing and cleared to 0 after. 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_cmd triggers save_memory() from ladder logic.
  • system.storage.sd.delete_all_cmd removes only retentive files (/sd/memory.json and /sd/_memory.tmp).
  • system.storage.sd.eject_cmd calls storage.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:

with Program() as logic:
    with Rung(Bool("PersistNow")):
        out(board.save_memory_cmd)

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, default 30)
  • run_when_high polarity control
  • optional exposure of sys.mode_run and sys.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

  1. Install CircuitPython on the P1AM-200
  2. Install the CircuitPython P1AM library and its dependencies into CIRCUITPY/lib/
  3. Download pyrung_rt.mpy from the pyrung releases page and copy it to CIRCUITPY/lib/
  4. Insert a FAT-formatted SD card for retentive tag storage (if using retentive tags)

Iterate

write_circuitpy(logic, hw, target_scan_ms=10.0, output_dir=".")

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_ms accordingly.
  • 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 FunctionCallInstruction callables 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