Skip to content

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 True scan 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

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

  • 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, 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:

  1. Importstime, json, board, busio, P1AM, sdcardio, storage, microcontroller
  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. Helper functions — rising edge, type conversions, math helpers
  8. 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).

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).

  1. Call generate_circuitpy() to produce the source string
  2. Write it to a file (e.g. code.py)
  3. Copy code.py to the P1AM-200's CIRCUITPY drive — it runs automatically on boot
  4. 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