180 lines
7.0 KiB
Markdown
180 lines
7.0 KiB
Markdown
# 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://<host>:8000`
|
||
API docs: `http://<host>: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 (dual-connection), signal cache, 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 (two TCP connections per device)
|
||
│ ┌─ _reader conn ── FC02 read discrete inputs (digital)
|
||
│ │ FC04 read input registers (analog)
|
||
│ └─ _writer conn ── FC05/FC06 write single coil/register
|
||
│ FC15/FC16 write multiple coils/registers
|
||
└─ _PollThread (daemon, reads via _reader 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.
|
||
|
||
### Dual-connection architecture
|
||
|
||
Each `TerminatorIO` opens two independent TCP connections to the EBC100:
|
||
a **reader** (used exclusively by the poll thread for FC02/FC04) and a
|
||
**writer** (used by sequencer/API/TUI for FC05/FC06/FC15/FC16). Each has
|
||
its own lock and reconnect state. Writes never block behind poll reads,
|
||
reducing typical output actuation jitter from 5–35 ms to 5–19 ms.
|
||
|
||
### 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
|