updated README
This commit is contained in:
239
README.md
239
README.md
@@ -1,162 +1,171 @@
|
|||||||
# Arnold — Terminator I/O Server
|
# Arnold — Terminator I/O Server
|
||||||
|
|
||||||
Fast-poll Modbus TCP server for AutomationDirect Terminator I/O systems.
|
Fast-poll Modbus TCP server for AutomationDirect Terminator I/O (T1H-EBC100).
|
||||||
Reads all digital inputs at 20 Hz, exposes a REST API for signal state,
|
Digital and analog I/O, REST API, timed output sequences, web UI, TUI debugger.
|
||||||
and executes timed output sequences.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
server.py entrypoint — wires everything together
|
|
||||||
config.yaml edit this to describe your hardware
|
|
||||||
config_with_outputs.yaml example with input + output modules
|
|
||||||
runs.log JSON-lines sequence run history (created at runtime)
|
|
||||||
|
|
||||||
arnold/
|
|
||||||
config.py YAML loader, dataclasses, config validation
|
|
||||||
terminator_io.py Terminator I/O driver: Modbus TCP, signal cache, poll thread
|
|
||||||
sequencer.py Sequence execution engine
|
|
||||||
api.py FastAPI REST application
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip3 install pymodbus fastapi uvicorn pyyaml --break-system-packages
|
pip3 install pymodbus fastapi uvicorn pyyaml textual --break-system-packages
|
||||||
python3 server.py # uses config.yaml, port 8000
|
|
||||||
python3 server.py --config config_with_outputs.yaml --log-level debug
|
# 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Interactive API docs: `http://<pi-ip>:8000/docs`
|
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
|
## API
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/status` | Device comms health, poll rates, active sequence |
|
| GET | `/status` | Device health, poll rates, active run |
|
||||||
| GET | `/io` | All signal states (name → value/stale/updated_at) |
|
| GET | `/io` | All signal states |
|
||||||
| GET | `/io/{signal}` | Single signal with device/slot/point/modbus_address |
|
| GET | `/io/{signal}` | Single signal + metadata (category, value_type, modbus_space) |
|
||||||
| GET | `/sequences` | List sequences from config |
|
| POST | `/io/{signal}/write` | Write output: `{"value": true}` or `{"value": 4000}` |
|
||||||
| GET | `/sequences/{name}` | Sequence detail with step list |
|
| GET | `/config/signals` | Full signal config list (for UI bootstrap) |
|
||||||
| POST | `/sequences/{name}/run` | Start a sequence → `{run_id}` (409 if one is running) |
|
| GET | `/sequences` | List sequences |
|
||||||
| GET | `/runs/{run_id}` | Run result: pending/running/success/failed/error |
|
| GET | `/sequences/{name}` | Sequence detail with steps |
|
||||||
| GET | `/runs` | Recent run history (most recent first, `?limit=N`) |
|
| POST | `/sequences/{name}/run` | Start sequence (202), 409 if busy |
|
||||||
|
| GET | `/runs/{run_id}` | Run result |
|
||||||
|
| GET | `/runs` | Recent run history |
|
||||||
|
|
||||||
## Config file
|
## Config
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
devices:
|
devices:
|
||||||
- id: ebc100_main
|
- id: ebc100_main
|
||||||
host: 192.168.3.202
|
host: 192.168.3.202
|
||||||
port: 502 # default Modbus TCP port
|
port: 502
|
||||||
unit_id: 1 # EBC100 responds to any unit ID over TCP; use 1
|
unit_id: 1
|
||||||
poll_interval_ms: 50
|
poll_interval_ms: 50
|
||||||
modules:
|
modules:
|
||||||
- slot: 1
|
- { slot: 1, type: T1H-08TDS } # 8-pt digital input
|
||||||
type: T1H-08TDS # 8-pt 24VDC sinking input
|
- { slot: 2, type: T1H-08AD-1 } # 8-ch analog input (12-bit)
|
||||||
points: 8
|
- { slot: 3, type: T1K-16TD2-1 } # 16-pt digital output
|
||||||
- slot: 3
|
- { slot: 4, type: T1H-04DA-1 } # 4-ch analog output (12-bit)
|
||||||
type: T1K-16TD2-1 # 16-pt sourcing output
|
|
||||||
points: 16
|
|
||||||
|
|
||||||
logical_io:
|
logical_io:
|
||||||
- name: sensor_a
|
- { name: sensor_a, device: ebc100_main, slot: 1, point: 1, direction: input }
|
||||||
device: ebc100_main
|
- { name: pressure, device: ebc100_main, slot: 2, point: 1, direction: input }
|
||||||
slot: 1
|
- { name: valve_1, device: ebc100_main, slot: 3, point: 1, direction: output, default_state: false }
|
||||||
point: 1
|
- { name: dac_ch1, device: ebc100_main, slot: 4, point: 1, direction: output, default_value: 2048 }
|
||||||
direction: input
|
|
||||||
- name: valve_1
|
|
||||||
device: ebc100_main
|
|
||||||
slot: 3
|
|
||||||
point: 1
|
|
||||||
direction: output
|
|
||||||
|
|
||||||
sequences:
|
sequences:
|
||||||
- name: actuate
|
- name: actuate
|
||||||
description: "Open valve, verify sensor, close valve"
|
description: "Open valve, set DAC, verify sensor"
|
||||||
steps:
|
steps:
|
||||||
- { t_ms: 0, action: set_output, signal: valve_1, state: true }
|
- { t_ms: 0, action: set_output, signal: valve_1, state: true }
|
||||||
- { t_ms: 500, action: check_input, signal: sensor_a, expected: 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: 1000, action: set_output, signal: valve_1, state: false }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Timing:** `t_ms` is absolute from sequence T=0 (not relative delays).
|
`t_ms` is absolute from T=0 (not relative). Steps auto-sort by `t_ms`.
|
||||||
Steps are sorted by `t_ms` at load time; order in the file doesn't matter.
|
Failed `check_input` aborts immediately — remaining steps are skipped.
|
||||||
Multiple steps with the same `t_ms` execute in file order.
|
`wait_input` blocks until condition met or `timeout_ms` expires.
|
||||||
|
|
||||||
**Failure:** a failed `check_input` aborts the sequence immediately.
|
## Supported modules (44 types)
|
||||||
Remaining steps — including output resets — are skipped.
|
|
||||||
Add an explicit reset sequence (`all_outputs_off`) and call it after a failure.
|
|
||||||
|
|
||||||
## Supported module 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) |
|
||||||
|
|
||||||
| Type | Direction | Points |
|
Full registry in `arnold/module_types.py`.
|
||||||
|------|-----------|--------|
|
|
||||||
| T1H-08TDS, T1K-08TDS | input | 8 |
|
|
||||||
| T1H-08ND3, T1K-08ND3 | input | 8 |
|
|
||||||
| T1H-16ND3, T1K-16ND3 | input | 16 |
|
|
||||||
| T1H-08NA, T1K-08NA | input | 8 |
|
|
||||||
| T1H-08TD1, T1K-08TD1 | output | 8 |
|
|
||||||
| T1H-08TD2, T1K-08TD2 | output | 8 |
|
|
||||||
| T1H-16TD1, T1K-16TD1 | output | 16 |
|
|
||||||
| T1H-16TD2, T1K-16TD2, T1K-16TD2-1 | output | 16 |
|
|
||||||
| T1H-08TA, T1K-08TA | output | 8 |
|
|
||||||
| T1H-08TRS, T1K-08TRS | output | 8 |
|
|
||||||
|
|
||||||
## T1H-EBC100 hardware quirks
|
---
|
||||||
|
|
||||||
### Unified coil address space
|
## Architecture
|
||||||
|
|
||||||
The EBC100 maps **all modules — inputs and outputs — into a single flat
|
```
|
||||||
address space** ordered by physical slot number. There is no separate
|
server.py Entrypoint: config load, poll start, default writes, uvicorn
|
||||||
"input base address" and "output base address".
|
tui.py Textual TUI debugger (standalone, no API server)
|
||||||
|
probe_terminator.py Standalone Modbus probe script (not part of package)
|
||||||
|
|
||||||
Example: slot 1 = 8-pt input, slot 2 = 8-pt input, slot 3 = 16-pt output:
|
config.yaml Input-only hardware config
|
||||||
|
config_with_outputs.yaml Input + output hardware config
|
||||||
|
runs.log JSON-lines run history (created at runtime)
|
||||||
|
|
||||||
| Slot | Module | Points | Coil addresses |
|
arnold/
|
||||||
|------|--------|--------|----------------|
|
__init__.py
|
||||||
| 1 | T1H-08TDS (input) | 8 | 0–7 |
|
module_types.py ModuleType frozen dataclass + 44-module registry
|
||||||
| 2 | T1H-08TDS (input) | 8 | 8–15 |
|
config.py YAML loader, validation, dual address space computation
|
||||||
| 3 | T1K-16TD2-1 (output) | 16 | **16–31** |
|
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
|
||||||
|
|
||||||
FC05/FC15 output writes must use these unified addresses.
|
web/
|
||||||
The config loader computes `modbus_address` for every module and signal
|
index.html Single-page app shell
|
||||||
automatically — you never write raw addresses in YAML.
|
style.css Dark theme, responsive (desktop/tablet/phone)
|
||||||
|
app.js Vanilla JS: polling, output control, sequence runner
|
||||||
|
```
|
||||||
|
|
||||||
### FC02 input reads start at address 0
|
### Data flow
|
||||||
|
|
||||||
FC02 (read discrete inputs) returns only input bits, starting at bit index 0,
|
```
|
||||||
regardless of where inputs sit in the unified space. The poll thread reads
|
YAML config ──► config.py ──► module_types.py (resolve part numbers)
|
||||||
`total_input_points` bits from FC02 address 0. Because `modbus_address` for
|
│
|
||||||
input signals equals their FC02 bit index (inputs occupy the lowest slots in
|
▼
|
||||||
practice), no remapping is needed.
|
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
|
||||||
|
```
|
||||||
|
|
||||||
### No exception on out-of-range addresses
|
### Dual address spaces
|
||||||
|
|
||||||
The EBC100 returns zeros for any FC02 read address beyond the installed
|
The EBC100 has two independent flat address spaces:
|
||||||
modules — it never raises Modbus exception code 2 (illegal data address).
|
|
||||||
Module presence **cannot** be auto-detected from protocol errors.
|
|
||||||
The `modules` list in the config is authoritative.
|
|
||||||
|
|
||||||
### FC05 write echo
|
- **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.
|
||||||
|
|
||||||
`write_coil` (FC05) echoes back `True` for any address, even unmapped ones.
|
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.
|
||||||
There is no error feedback for writes to non-existent output points.
|
|
||||||
Config validation at startup prevents invalid addresses from being used.
|
|
||||||
|
|
||||||
### Unit ID is ignored
|
### EBC100 quirks
|
||||||
|
|
||||||
The EBC100 accepts and echoes back any Modbus unit/slave ID over TCP.
|
- Returns zeros for out-of-range reads (no Modbus exception code 2)
|
||||||
Set `unit_id: 1` in the config (standard default).
|
- 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
|
- No unsolicited push — polling mandatory
|
||||||
|
- UDP 502 inactive; ports 443, 503, 8080 closed
|
||||||
Modbus TCP is a strictly polled protocol; the EBC100 has no push capability.
|
|
||||||
The server polls at `poll_interval_ms` (default 50 ms = 20 Hz).
|
|
||||||
At 24 input points a single FC02 read takes ~1 ms on a local network.
|
|
||||||
|
|
||||||
### Web interface
|
|
||||||
|
|
||||||
The EBC100 hosts a minimal HTTP server on port 80 (firmware by Host Engineering).
|
|
||||||
It exposes IP/subnet/gateway config and serial port mode only — no I/O data.
|
|
||||||
Port 443, 503, and 8080 are closed. UDP port 502 is not active.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user