# Arnold — Terminator I/O Server Fast-poll Modbus TCP server for AutomationDirect Terminator I/O (T1H-EBC100). Digital and analog I/O, REST API, timed output sequences, web UI, TUI debugger. ## Quick start ```bash pip3 install pymodbus fastapi uvicorn pyyaml textual --break-system-packages # REST API + web UI python3 server.py --config config_with_outputs.yaml # TUI debugger (standalone, no API server) python3 tui.py config_with_outputs.yaml ``` Web UI: `http://:8000` API docs: `http://:8000/docs` ## Web UI Three-panel layout (mirrors the TUI). Polls I/O at ~20 Hz. - **Inputs** — live digital (dot indicators) and analog (raw integer) values - **Outputs** — click to toggle digital; +/- and Write for analog (0–65535) - **Sequences** — run button, live step list with progress bar Responsive: 3-column on desktop, stacked on tablet/phone. ## TUI Terminal debugger with the same three panels. | Key | Action | |-----|--------| | Tab | Cycle focus: Outputs / Sequences | | Space/Enter | Toggle digital output / Run sequence | | +/- | Adjust analog output (step 100) | | Enter | Commit analog value | | 0 / 1 | All digital outputs OFF / ON | | r | Reconnect all devices | | q | Quit | ## API | Method | Path | Description | |--------|------|-------------| | GET | `/status` | Device health, poll rates, active run | | GET | `/io` | All signal states | | GET | `/io/{signal}` | Single signal + metadata (category, value_type, modbus_space) | | POST | `/io/{signal}/write` | Write output: `{"value": true}` or `{"value": 4000}` | | GET | `/config/signals` | Full signal config list (for UI bootstrap) | | GET | `/sequences` | List sequences | | GET | `/sequences/{name}` | Sequence detail with steps | | POST | `/sequences/{name}/run` | Start sequence (202), 409 if busy | | GET | `/runs/{run_id}` | Run result | | GET | `/runs` | Recent run history | ## Config ```yaml devices: - id: ebc100_main host: 192.168.3.202 port: 502 unit_id: 1 poll_interval_ms: 50 modules: - { slot: 1, type: T1H-08TDS } # 8-pt digital input - { slot: 2, type: T1H-08AD-1 } # 8-ch analog input (12-bit) - { slot: 3, type: T1K-16TD2-1 } # 16-pt digital output - { slot: 4, type: T1H-04DA-1 } # 4-ch analog output (12-bit) logical_io: - { name: sensor_a, device: ebc100_main, slot: 1, point: 1, direction: input } - { name: pressure, device: ebc100_main, slot: 2, point: 1, direction: input } - { name: valve_1, device: ebc100_main, slot: 3, point: 1, direction: output, default_state: false } - { name: dac_ch1, device: ebc100_main, slot: 4, point: 1, direction: output, default_value: 2048 } sequences: - name: actuate description: "Open valve, set DAC, verify sensor" steps: - { t_ms: 0, action: set_output, signal: valve_1, state: true } - { t_ms: 100, action: set_output, signal: dac_ch1, value: 4000 } - { t_ms: 500, action: check_input, signal: sensor_a, expected: true } - { t_ms: 600, action: check_input, signal: pressure, expected_value: 2000, tolerance: 100 } - { t_ms: 1000, action: set_output, signal: valve_1, state: false } ``` `t_ms` is absolute from T=0 (not relative). Steps auto-sort by `t_ms`. Failed `check_input` aborts immediately — remaining steps are skipped. `wait_input` blocks until condition met or `timeout_ms` expires. ## Supported modules (44 types) | Category | Examples | Address space | |----------|----------|---------------| | Digital input | T1H-08TDS, T1H-16ND3, T1K-08ND3, ... | coil (FC02) | | Digital output | T1H-08TD1, T1K-16TD2-1, T1H-08TA, ... | coil (FC05/FC15) | | Relay output | T1H-08TRS, T1H-16TRS2, T1K-08TRS | coil (FC05/FC15) | | Analog input | T1H-08AD-1 (12-bit), T1H-16AD-2 (15-bit), ... | register (FC04) | | Analog output | T1H-04DA-1 (12-bit), T1H-08DA-2 (15-bit), ... | register (FC06/FC16) | | Temperature input | T1H-08THM, T1H-04RTD, T1K-08THM, T1K-04RTD | register (FC04) | Full registry in `arnold/module_types.py`. --- ## Architecture ``` server.py Entrypoint: config load, poll start, default writes, uvicorn tui.py Textual TUI debugger (standalone, no API server) probe_terminator.py Standalone Modbus probe script (not part of package) config.yaml Input-only hardware config config_with_outputs.yaml Input + output hardware config runs.log JSON-lines run history (created at runtime) arnold/ __init__.py module_types.py ModuleType frozen dataclass + 44-module registry config.py YAML loader, validation, dual address space computation terminator_io.py Modbus TCP driver, signal cache, dual-space poll thread sequencer.py Sequence engine: timing, digital+analog set/check/wait api.py FastAPI app: REST endpoints, static file mount for web UI web/ index.html Single-page app shell style.css Dark theme, responsive (desktop/tablet/phone) app.js Vanilla JS: polling, output control, sequence runner ``` ### Data flow ``` YAML config ──► config.py ──► module_types.py (resolve part numbers) │ ▼ terminator_io.py ┌─ TerminatorIO (Modbus TCP client per device) │ FC02 read discrete inputs (digital) │ FC04 read input registers (analog) │ FC05/FC06 write single coil/register │ FC15/FC16 write multiple coils/registers └─ _PollThread (daemon, reads FC02+FC04 each cycle) └─ IORegistry (multi-device coordinator, signal cache) │ ┌────────┼────────┐ ▼ ▼ ▼ api.py sequencer tui.py / web UI ``` ### Dual address spaces The EBC100 has two independent flat address spaces: - **Coil space** (1-bit) — digital modules. FC01/FC02/FC05/FC15. Sequential by slot. - **Register space** (16-bit) — analog/temperature modules. FC03/FC04/FC06/FC16. Sequential by slot. A digital module advances only `coil_offset`. An analog module advances only `register_offset`. They do not interfere. `config.py` computes all addresses at load time. ### EBC100 quirks - Returns zeros for out-of-range reads (no Modbus exception code 2) - FC05 echoes True for any address, even unmapped — no write error feedback - Responds to any unit ID (echoed, not routed). Use `unit_id: 1` - No unsolicited push — polling mandatory - UDP 502 inactive; ports 443, 503, 8080 closed