# 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](https://facts-engineering.github.io/modules/P1AM-200/P1AM-200.html): 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

```bash
pip install pyrung
```

```python
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](https://facts-engineering.github.io/modules/P1AM-200/P1AM-200.html) is a base unit with up to 15 slots for [Productivity1000 I/O modules](https://www.automationdirect.com/adc/overview/catalog/programmable_controllers/productivity1000_plcs_(stackable_micro)). Configure hardware with the `P1AM` class:

```python
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:

```python
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](https://facts-engineering.github.io/) 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`](https://facts-engineering.github.io/modules/P1-04PWM/P1-04PWM.html) (PWM) and [`P1-02HSC`](https://facts-engineering.github.io/modules/P1-02HSC/P1-02HSC.html) (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.

```python
from pyrung import Bool, Int, PLCRunner, Program, Rung, TimeMode, 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
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 code.py — copy to CIRCUITPY drive
write_circuitpy(logic, hw, target_scan_ms=10.0, watchdog_ms=500, output_dir=".")
```

## Code generation

```python
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):

```python
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](circuitpy-modbus.md) |
| `modbus_client` | `ModbusClientConfig \| None` | Modbus TCP client config; see [Modbus TCP](circuitpy-modbus.md) |
| `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. **Imports** — `time`, `json`, `board`, `busio`, `P1AM`, `sdcardio`, `storage`, `microcontroller`, `pyrung_rt`
2. **Configuration** — `TARGET_SCAN_MS`, `WATCHDOG_MS`, slot module list, retentive schema hash
3. **Hardware bootstrap** — `P1AM.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:

```python
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:

- [CircuitPython Modbus TCP](circuitpy-modbus.md)

## Validation

```python
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](https://circuitpython.org/board/p1am_200/) on the P1AM-200
2. Install the [CircuitPython P1AM library](https://github.com/facts-engineering/CircuitPython_P1AM) and its dependencies into `CIRCUITPY/lib/`
3. Download `pyrung_rt.mpy` from the [pyrung releases page](https://github.com/ssweber/pyrung/releases) and copy it to `CIRCUITPY/lib/`
4. Insert a FAT-formatted SD card for retentive tag storage (if using retentive tags)

### Iterate

```python
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

- [P1AM-200 documentation](https://facts-engineering.github.io/modules/P1AM-200/P1AM-200.html) — hardware specs, pinout, getting started
- [CircuitPython P1AM library](https://github.com/facts-engineering/CircuitPython_P1AM) — the runtime library used by generated code
- [Productivity1000 I/O module docs](https://facts-engineering.github.io/) — per-module wiring diagrams and specs
- [P1AM-200 on AutomationDirect](https://www.automationdirect.com/adc/shopping/catalog/programmable_controllers/productivity_open_(arduino-compatible)/controllers_-a-_shields/p1am-200) — ordering and datasheets
- [CircuitPython documentation](https://docs.circuitpython.org/) — language reference for the target runtime
