From 75678fce4d942719a02d9d0edf97bf9a2e851e81 Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Mon, 2 Mar 2026 17:48:55 -0500 Subject: [PATCH] first --- README.md | 162 ++++ __pycache__/server.cpython-311.pyc | Bin 0 -> 7450 bytes __pycache__/tui.cpython-311.pyc | Bin 0 -> 37363 bytes arnold/__init__.py | 2 + arnold/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 171 bytes arnold/__pycache__/api.cpython-311.pyc | Bin 0 -> 12835 bytes arnold/__pycache__/config.cpython-311.pyc | Bin 0 -> 28233 bytes arnold/__pycache__/io_driver.cpython-311.pyc | Bin 0 -> 11657 bytes arnold/__pycache__/io_state.cpython-311.pyc | Bin 0 -> 6494 bytes .../__pycache__/module_types.cpython-311.pyc | Bin 0 -> 9442 bytes arnold/__pycache__/poller.cpython-311.pyc | Bin 0 -> 8980 bytes arnold/__pycache__/sequencer.cpython-311.pyc | Bin 0 -> 21865 bytes .../__pycache__/terminator_io.cpython-311.pyc | Bin 0 -> 34216 bytes arnold/api.py | 263 ++++++ arnold/config.py | 537 +++++++++++ arnold/module_types.py | 289 ++++++ arnold/sequencer.py | 494 ++++++++++ arnold/terminator_io.py | 663 +++++++++++++ config.yaml | 329 +++++++ config_with_outputs.yaml | 149 +++ probe_terminator.py | 281 ++++++ runs.log | 12 + server.py | 156 ++++ tui.py | 883 ++++++++++++++++++ web/app.js | 600 ++++++++++++ web/index.html | 71 ++ web/style.css | 463 +++++++++ 27 files changed, 5354 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/server.cpython-311.pyc create mode 100644 __pycache__/tui.cpython-311.pyc create mode 100644 arnold/__init__.py create mode 100644 arnold/__pycache__/__init__.cpython-311.pyc create mode 100644 arnold/__pycache__/api.cpython-311.pyc create mode 100644 arnold/__pycache__/config.cpython-311.pyc create mode 100644 arnold/__pycache__/io_driver.cpython-311.pyc create mode 100644 arnold/__pycache__/io_state.cpython-311.pyc create mode 100644 arnold/__pycache__/module_types.cpython-311.pyc create mode 100644 arnold/__pycache__/poller.cpython-311.pyc create mode 100644 arnold/__pycache__/sequencer.cpython-311.pyc create mode 100644 arnold/__pycache__/terminator_io.cpython-311.pyc create mode 100644 arnold/api.py create mode 100644 arnold/config.py create mode 100644 arnold/module_types.py create mode 100644 arnold/sequencer.py create mode 100644 arnold/terminator_io.py create mode 100644 config.yaml create mode 100644 config_with_outputs.yaml create mode 100644 probe_terminator.py create mode 100644 runs.log create mode 100644 server.py create mode 100644 tui.py create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..37e1c6c --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Arnold — Terminator I/O Server + +Fast-poll Modbus TCP server for AutomationDirect Terminator I/O systems. +Reads all digital inputs at 20 Hz, exposes a REST API for signal state, +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 + +```bash +pip3 install pymodbus fastapi uvicorn pyyaml --break-system-packages +python3 server.py # uses config.yaml, port 8000 +python3 server.py --config config_with_outputs.yaml --log-level debug +``` + +Interactive API docs: `http://:8000/docs` + +## API + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/status` | Device comms health, poll rates, active sequence | +| GET | `/io` | All signal states (name → value/stale/updated_at) | +| GET | `/io/{signal}` | Single signal with device/slot/point/modbus_address | +| GET | `/sequences` | List sequences from config | +| GET | `/sequences/{name}` | Sequence detail with step list | +| POST | `/sequences/{name}/run` | Start a sequence → `{run_id}` (409 if one is running) | +| GET | `/runs/{run_id}` | Run result: pending/running/success/failed/error | +| GET | `/runs` | Recent run history (most recent first, `?limit=N`) | + +## Config file + +```yaml +devices: + - id: ebc100_main + host: 192.168.3.202 + port: 502 # default Modbus TCP port + unit_id: 1 # EBC100 responds to any unit ID over TCP; use 1 + poll_interval_ms: 50 + modules: + - slot: 1 + type: T1H-08TDS # 8-pt 24VDC sinking input + points: 8 + - slot: 3 + type: T1K-16TD2-1 # 16-pt sourcing output + points: 16 + +logical_io: + - name: sensor_a + device: ebc100_main + slot: 1 + point: 1 + direction: input + - name: valve_1 + device: ebc100_main + slot: 3 + point: 1 + direction: output + +sequences: + - name: actuate + description: "Open valve, verify sensor, close valve" + steps: + - { t_ms: 0, action: set_output, signal: valve_1, state: true } + - { t_ms: 500, action: check_input, signal: sensor_a, expected: true } + - { t_ms: 1000, action: set_output, signal: valve_1, state: false } +``` + +**Timing:** `t_ms` is absolute from sequence T=0 (not relative delays). +Steps are sorted by `t_ms` at load time; order in the file doesn't matter. +Multiple steps with the same `t_ms` execute in file order. + +**Failure:** a failed `check_input` aborts the sequence immediately. +Remaining steps — including output resets — are skipped. +Add an explicit reset sequence (`all_outputs_off`) and call it after a failure. + +## Supported module types + +| Type | Direction | Points | +|------|-----------|--------| +| 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 + +The EBC100 maps **all modules — inputs and outputs — into a single flat +address space** ordered by physical slot number. There is no separate +"input base address" and "output base address". + +Example: slot 1 = 8-pt input, slot 2 = 8-pt input, slot 3 = 16-pt output: + +| Slot | Module | Points | Coil addresses | +|------|--------|--------|----------------| +| 1 | T1H-08TDS (input) | 8 | 0–7 | +| 2 | T1H-08TDS (input) | 8 | 8–15 | +| 3 | T1K-16TD2-1 (output) | 16 | **16–31** | + +FC05/FC15 output writes must use these unified addresses. +The config loader computes `modbus_address` for every module and signal +automatically — you never write raw addresses in YAML. + +### FC02 input reads start at address 0 + +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 +`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. + +### No exception on out-of-range addresses + +The EBC100 returns zeros for any FC02 read address beyond the installed +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 + +`write_coil` (FC05) echoes back `True` for any address, even unmapped ones. +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 + +The EBC100 accepts and echoes back any Modbus unit/slave ID over TCP. +Set `unit_id: 1` in the config (standard default). + +### No unsolicited push + +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. diff --git a/__pycache__/server.cpython-311.pyc b/__pycache__/server.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..780628dada5cf6b4edec9a910acffbd109fe5e28 GIT binary patch literal 7450 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 ZnjO@RFOGdWervqQexUbw4^%Vm{U5rAv?u@o literal 0 HcmV?d00001 diff --git a/__pycache__/tui.cpython-311.pyc b/__pycache__/tui.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5bb31c1b9d8bb86ee52c55cde691b3c293154644 GIT binary patch literal 37363 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 literal 0 HcmV?d00001 diff --git a/arnold/__pycache__/api.cpython-311.pyc b/arnold/__pycache__/api.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7d6436001b7d948cf717412acfedd545c557d314 GIT binary patch literal 12835 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 literal 0 HcmV?d00001 diff --git a/arnold/__pycache__/io_driver.cpython-311.pyc b/arnold/__pycache__/io_driver.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7d1c75a3f71f46e2c01e81c01bd20fcc9cd964f2 GIT binary patch literal 11657 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 literal 0 HcmV?d00001 diff --git a/arnold/__pycache__/io_state.cpython-311.pyc b/arnold/__pycache__/io_state.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7585f20a6669d7530ac3b27b7683c7f681c3b584 GIT binary patch literal 6494 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 Y#(qb#zO2OZdATFbNTNe> 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 literal 0 HcmV?d00001 diff --git a/arnold/__pycache__/poller.cpython-311.pyc b/arnold/__pycache__/poller.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fd0dfec3daba9c292252c231cd100b40f638863 GIT binary patch literal 8980 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 HzzFw05yY(t literal 0 HcmV?d00001 diff --git a/arnold/__pycache__/sequencer.cpython-311.pyc b/arnold/__pycache__/sequencer.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b2e9d9e819cf737765be5cda609efccf6849a070 GIT binary patch literal 21865 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 At^fc4 literal 0 HcmV?d00001 diff --git a/arnold/__pycache__/terminator_io.cpython-311.pyc b/arnold/__pycache__/terminator_io.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5eb93c39657255fb91e79855ea3d8fa389bce69e GIT binary patch literal 34216 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 X2RY=DY&ML=;_xB!e}9d)Y_$G=t6cVp literal 0 HcmV?d00001 diff --git a/arnold/api.py b/arnold/api.py new file mode 100644 index 0000000..8a0e80b --- /dev/null +++ b/arnold/api.py @@ -0,0 +1,263 @@ +""" +arnold/api.py — FastAPI REST application. + +Receives an AppContext at creation time; no module-level mutable globals. + +Endpoints: + GET / Redirect to web UI + GET /web/* Static files (web interface) + GET /status Device comms health + poll stats + GET /io All signal states from the poll cache + GET /io/{signal} Single signal state + metadata + POST /io/{signal}/write Write an output signal value + GET /config/signals Full signal metadata for UI bootstrap + GET /sequences List sequences defined in config + GET /sequences/{name} Sequence detail with step list + POST /sequences/{name}/run Start a sequence → {run_id} (409 if busy) + GET /runs Recent run log (last 50, most recent first) + GET /runs/{run_id} Single run result (pending/running/success/failed/error) +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from .config import Config +from .sequencer import Sequencer +from .terminator_io import IORegistry + + +@dataclass +class AppContext: + """All runtime objects the API needs, passed in at app creation.""" + config: Config + registry: IORegistry + sequencer: Sequencer + started_at: float + + +def create_app(ctx: AppContext) -> FastAPI: + app = FastAPI( + title="Arnold — Terminator I/O Server", + version="0.1.0", + description=( + "Fast-poll Modbus TCP I/O server for AutomationDirect Terminator I/O. " + "Provides real-time signal state and timed sequence execution." + ), + ) + + # ------------------------------------------------------------------ + # Web UI — static files + root redirect + # ------------------------------------------------------------------ + + # Locate the web/ directory relative to this file + _web_dir = Path(__file__).resolve().parent.parent / "web" + if _web_dir.is_dir(): + app.mount("/web", StaticFiles(directory=str(_web_dir)), name="web-static") + + @app.get("/", include_in_schema=False) + def root_redirect(): + return RedirectResponse(url="/web/index.html") + + # ------------------------------------------------------------------ + # GET /status + # ------------------------------------------------------------------ + + @app.get("/status", summary="Device comms health and poll statistics") + def get_status() -> dict: + return { + "status": "ok", + "uptime_s": round(time.monotonic() - ctx.started_at, 1), + "devices": ctx.registry.driver_status(), + "poll_stats": ctx.registry.poll_stats(), + "active_run": ctx.sequencer.active_run_id(), + } + + # ------------------------------------------------------------------ + # GET /io + # ------------------------------------------------------------------ + + @app.get("/io", summary="All signal states") + def get_all_io() -> dict[str, Any]: + return { + name: { + "value": s.value, + "stale": s.stale, + "updated_at": s.updated_at, + } + for name, s in ctx.registry.snapshot().items() + } + + # ------------------------------------------------------------------ + # GET /io/{signal} + # ------------------------------------------------------------------ + + @app.get("/io/{signal_name}", summary="Single signal state") + def get_signal(signal_name: str) -> dict: + sig = ctx.config.signal(signal_name) + if sig is None: + raise HTTPException(404, f"Unknown signal: {signal_name!r}") + + s = ctx.registry.get(signal_name) + return { + "name": signal_name, + "direction": sig.direction, + "category": sig.category, + "value_type": sig.value_type, + "modbus_space": sig.modbus_space, + "device": sig.device, + "slot": sig.slot, + "point": sig.point, + "modbus_address": sig.modbus_address, + "value": s.value if s else None, + "stale": s.stale if s else True, + "updated_at": s.updated_at if s else None, + } + + # ------------------------------------------------------------------ + # POST /io/{signal}/write — write an output value + # ------------------------------------------------------------------ + + class WriteRequest(BaseModel): + value: bool | int # bool for digital, int for analog + + @app.post("/io/{signal_name}/write", summary="Write an output signal") + def write_signal(signal_name: str, req: WriteRequest) -> dict: + sig = ctx.config.signal(signal_name) + if sig is None: + raise HTTPException(404, f"Unknown signal: {signal_name!r}") + if sig.direction != "output": + raise HTTPException(400, f"Signal {signal_name!r} is an input, not writable") + + driver = ctx.registry.driver(sig.device) + if driver is None: + raise HTTPException(503, f"No driver for device {sig.device!r}") + + if sig.value_type == "int": + val = int(req.value) + if val < 0 or val > 65535: + raise HTTPException(400, f"Analog value must be 0–65535, got {val}") + ok = driver.write_register(sig.modbus_address, val) + else: + val = bool(req.value) + ok = driver.write_output(sig.modbus_address, val) + + if not ok: + raise HTTPException(502, f"Write failed for {signal_name!r}") + + return {"signal": signal_name, "value": val, "ok": True} + + # ------------------------------------------------------------------ + # GET /config/signals — full signal metadata for UI bootstrap + # ------------------------------------------------------------------ + + @app.get("/config/signals", summary="All signal metadata from config") + def get_config_signals() -> list[dict]: + return [ + { + "name": s.name, + "direction": s.direction, + "category": s.category, + "value_type": s.value_type, + "modbus_space": s.modbus_space, + "device": s.device, + "slot": s.slot, + "point": s.point, + "modbus_address": s.modbus_address, + "default_state": s.default_state, + "default_value": s.default_value, + } + for s in ctx.config.logical_io + ] + + # ------------------------------------------------------------------ + # GET /sequences + # ------------------------------------------------------------------ + + @app.get("/sequences", summary="List all sequences") + def list_sequences() -> list[dict]: + return [ + {"name": seq.name, "description": seq.description, "steps": len(seq.steps)} + for seq in ctx.config.sequences + ] + + # ------------------------------------------------------------------ + # GET /sequences/{name} + # ------------------------------------------------------------------ + + @app.get("/sequences/{name}", summary="Sequence detail") + def get_sequence(name: str) -> dict: + seq = ctx.config.sequence(name) + if seq is None: + raise HTTPException(404, f"Unknown sequence: {name!r}") + return { + "name": seq.name, + "description": seq.description, + "steps": [ + { + "t_ms": step.t_ms, + "action": step.action, + "signal": step.signal, + # Digital fields (None for analog) + "state": step.state, + "expected": step.expected, + # Analog fields (None for digital) + "value": step.value, + "expected_value": step.expected_value, + "tolerance": step.tolerance, + # wait_input + "timeout_ms": step.timeout_ms, + } + for step in seq.steps + ], + } + + # ------------------------------------------------------------------ + # POST /sequences/{name}/run + # ------------------------------------------------------------------ + + @app.post("/sequences/{name}/run", summary="Start a sequence", status_code=202) + def run_sequence(name: str) -> dict: + if ctx.config.sequence(name) is None: + raise HTTPException(404, f"Unknown sequence: {name!r}") + + run_id, started = ctx.sequencer.start(name) + + if not started: + active = ctx.sequencer.active_run_id() + raise HTTPException( + 409, + f"Sequence already running (run_id={active!r}). " + f"Poll GET /runs/{active} for status.", + ) + + return {"run_id": run_id, "sequence": name, "status": "running"} + + # ------------------------------------------------------------------ + # GET /runs/{run_id} + # ------------------------------------------------------------------ + + @app.get("/runs/{run_id}", summary="Run result by ID") + def get_run(run_id: str) -> dict: + result = ctx.sequencer.get_result(run_id) + if result is None: + raise HTTPException(404, f"Unknown run_id: {run_id!r}") + return result.to_dict() + + # ------------------------------------------------------------------ + # GET /runs + # ------------------------------------------------------------------ + + @app.get("/runs", summary="Recent run history") + def list_runs(limit: int = 50) -> list[dict]: + return ctx.sequencer.recent_runs(min(limit, 200)) + + return app diff --git a/arnold/config.py b/arnold/config.py new file mode 100644 index 0000000..03381e1 --- /dev/null +++ b/arnold/config.py @@ -0,0 +1,537 @@ +""" +arnold/config.py — YAML config loader, dataclasses, and validation. + +Schema: + devices: list of EBC100 controllers + logical_io: named signals mapped to device/slot/point + sequences: ordered step lists with timing and actions + +Address spaces (EBC100 hardware): + The T1H-EBC100 maintains TWO independent Modbus address spaces: + + coil space (1-bit) — digital modules (inputs + outputs + relays) + FC01 read coils, FC02 read discrete inputs, + FC05 write single coil, FC15 write multiple coils + Flat sequential addressing by physical slot order. + + register space (16-bit) — analog + temperature modules + FC03 read holding registers, FC04 read input registers, + FC06 write single register, FC16 write multiple registers + Flat sequential addressing by physical slot order, + INDEPENDENT of coil space. + + A digital module in slot 1 advances coil_offset but not register_offset. + An analog module in slot 2 advances register_offset but not coil_offset. +""" + +from __future__ import annotations + +import yaml +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +from .module_types import MODULE_REGISTRY, ModuleType, Category, get_module_type + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class ModuleConfig: + slot: int # 1-based physical slot number + type: str # part number, e.g. "T1H-08TDS" + module_type: ModuleType # resolved from registry + points: int # number of I/O points/channels + category: Category # from ModuleType + modbus_space: Literal["coil", "register"] # from ModuleType + # Modbus base address (0-based) within the appropriate space, + # computed at load time. + modbus_address: int = 0 + + +@dataclass +class DeviceConfig: + id: str + host: str + port: int + unit_id: int + poll_interval_ms: int + modules: list[ModuleConfig] = field(default_factory=list) + + def module_for_slot(self, slot: int) -> ModuleConfig | None: + return next((m for m in self.modules if m.slot == slot), None) + + # -- Digital helpers --------------------------------------------------- + + def digital_input_modules(self) -> list[ModuleConfig]: + return [m for m in self.modules + if m.category == "digital_input"] + + def digital_output_modules(self) -> list[ModuleConfig]: + return [m for m in self.modules + if m.category in ("digital_output", "relay_output")] + + def total_input_points(self) -> int: + """Total discrete input bits (for FC02 bulk read).""" + return sum(m.points for m in self.digital_input_modules()) + + def total_output_points(self) -> int: + """Total discrete output bits.""" + return sum(m.points for m in self.digital_output_modules()) + + # -- Analog helpers ---------------------------------------------------- + + def analog_input_modules(self) -> list[ModuleConfig]: + return [m for m in self.modules + if m.category in ("analog_input", "temperature_input")] + + def analog_output_modules(self) -> list[ModuleConfig]: + return [m for m in self.modules + if m.category == "analog_output"] + + def total_analog_input_channels(self) -> int: + """Total 16-bit input registers (for FC04 bulk read).""" + return sum(m.points for m in self.analog_input_modules()) + + def total_analog_output_channels(self) -> int: + """Total 16-bit output registers.""" + return sum(m.points for m in self.analog_output_modules()) + + # -- Generic helpers --------------------------------------------------- + + def input_modules(self) -> list[ModuleConfig]: + """All modules that are inputs (digital + analog + temperature).""" + return [m for m in self.modules + if m.module_type.direction == "input"] + + def output_modules(self) -> list[ModuleConfig]: + """All modules that are outputs (digital + relay + analog).""" + return [m for m in self.modules + if m.module_type.direction == "output"] + + +@dataclass +class LogicalIO: + name: str + device: str # device id + slot: int # 1-based + point: int # 1-based within slot + direction: Literal["input", "output"] + category: Category # full category from ModuleType + modbus_space: Literal["coil", "register"] # which address space + value_type: Literal["bool", "int"] # bool for digital, int for analog + # For digital outputs: optional startup default + default_state: bool | None = None + # For analog outputs: optional startup default (raw register value) + default_value: int | None = None + # Resolved at load time: + modbus_address: int = 0 # 0-based within the appropriate space + device_ref: DeviceConfig | None = None + module_ref: ModuleConfig | None = None + + +@dataclass +class SequenceStep: + t_ms: int # absolute ms from sequence T=0 + action: Literal["set_output", "check_input", "wait_input"] + signal: str # logical_io name + # For digital set_output: + state: bool | None = None + # For analog set_output (raw register value): + value: int | None = None + # For check_input / wait_input (digital): + expected: bool | None = None + # For check_input / wait_input (analog — raw register value): + expected_value: int | None = None + tolerance: int | None = None # analog: abs(actual - expected) <= tolerance + # For wait_input only: + timeout_ms: int | None = None + + +@dataclass +class Sequence: + name: str + description: str + steps: list[SequenceStep] = field(default_factory=list) + + +@dataclass +class Config: + devices: list[DeviceConfig] + logical_io: list[LogicalIO] + sequences: list[Sequence] + + def device(self, device_id: str) -> DeviceConfig | None: + return next((d for d in self.devices if d.id == device_id), None) + + def signal(self, name: str) -> LogicalIO | None: + return next((s for s in self.logical_io if s.name == name), None) + + def sequence(self, name: str) -> Sequence | None: + return next((s for s in self.sequences if s.name == name), None) + + +# --------------------------------------------------------------------------- +# Loader +# --------------------------------------------------------------------------- + +class ConfigError(Exception): + pass + + +def _parse_bool(val: object, context: str) -> bool: + if isinstance(val, bool): + return val + if isinstance(val, str): + if val.lower() in ("true", "yes", "on", "1"): + return True + if val.lower() in ("false", "no", "off", "0"): + return False + raise ConfigError(f"{context}: expected boolean, got {val!r}") + + +def load(path: str | Path) -> Config: + """Load and validate a YAML config file. Raises ConfigError on any problem.""" + path = Path(path) + if not path.exists(): + raise ConfigError(f"Config file not found: {path}") + + with open(path) as f: + raw = yaml.safe_load(f) + + if not isinstance(raw, dict): + raise ConfigError("Config file must be a YAML mapping at the top level") + + devices = _parse_devices(raw.get("devices") or []) + device_map = {d.id: d for d in devices} + + logical_io = _parse_logical_io(raw.get("logical_io") or [], device_map) + signal_map = {s.name: s for s in logical_io} + + sequences = _parse_sequences(raw.get("sequences") or [], signal_map) + + return Config(devices=devices, logical_io=logical_io, sequences=sequences) + + +# --------------------------------------------------------------------------- +# Parsing helpers +# --------------------------------------------------------------------------- + +def _parse_devices(raw_list: list) -> list[DeviceConfig]: + if not isinstance(raw_list, list): + raise ConfigError("'devices' must be a list") + + devices: list[DeviceConfig] = [] + seen_ids: set[str] = set() + + for i, raw in enumerate(raw_list): + ctx = f"devices[{i}]" + if not isinstance(raw, dict): + raise ConfigError(f"{ctx}: must be a mapping") + + dev_id = _require_str(raw, "id", ctx) + if dev_id in seen_ids: + raise ConfigError(f"{ctx}: duplicate device id {dev_id!r}") + seen_ids.add(dev_id) + + host = _require_str(raw, "host", ctx) + port = int(raw.get("port", 502)) + unit_id = int(raw.get("unit_id", 1)) + poll_interval = int(raw.get("poll_interval_ms", 50)) + + modules = _parse_modules(raw.get("modules") or [], ctx) + + # Assign Modbus addresses using TWO independent counters. + # + # The T1H-EBC100 has separate address spaces for coils (digital) + # and registers (analog/temperature). Each space is flat and + # sequential by physical slot order. A digital module advances + # only the coil counter; an analog module advances only the + # register counter. + coil_offset = 0 + register_offset = 0 + for m in sorted(modules, key=lambda m: m.slot): + if m.modbus_space == "coil": + m.modbus_address = coil_offset + coil_offset += m.points + else: + m.modbus_address = register_offset + register_offset += m.points + + devices.append(DeviceConfig( + id=dev_id, + host=host, + port=port, + unit_id=unit_id, + poll_interval_ms=poll_interval, + modules=sorted(modules, key=lambda m: m.slot), + )) + + return devices + + +def _parse_modules(raw_list: list, parent_ctx: str) -> list[ModuleConfig]: + if not isinstance(raw_list, list): + raise ConfigError(f"{parent_ctx}.modules: must be a list") + + modules: list[ModuleConfig] = [] + seen_slots: set[int] = set() + + for i, raw in enumerate(raw_list): + ctx = f"{parent_ctx}.modules[{i}]" + if not isinstance(raw, dict): + raise ConfigError(f"{ctx}: must be a mapping") + + slot = int(_require(raw, "slot", ctx)) + mod_type = _require_str(raw, "type", ctx) + + if slot < 1: + raise ConfigError(f"{ctx}: slot must be >= 1, got {slot}") + if slot in seen_slots: + raise ConfigError(f"{ctx}: duplicate slot {slot}") + seen_slots.add(slot) + + try: + mt = get_module_type(mod_type) + except KeyError as exc: + raise ConfigError(f"{ctx}: {exc}") from None + + points = int(raw.get("points", mt.points)) + + modules.append(ModuleConfig( + slot=slot, + type=mod_type, + module_type=mt, + points=points, + category=mt.category, + modbus_space=mt.modbus_space, + )) + + return modules + + +def _parse_logical_io( + raw_list: list, device_map: dict[str, DeviceConfig] +) -> list[LogicalIO]: + if not isinstance(raw_list, list): + raise ConfigError("'logical_io' must be a list") + + signals: list[LogicalIO] = [] + seen_names: set[str] = set() + + for i, raw in enumerate(raw_list): + ctx = f"logical_io[{i}]" + if not isinstance(raw, dict): + raise ConfigError(f"{ctx}: must be a mapping") + + name = _require_str(raw, "name", ctx) + dev_id = _require_str(raw, "device", ctx) + slot = int(_require(raw, "slot", ctx)) + point = int(_require(raw, "point", ctx)) + direction = _require_str(raw, "direction", ctx) + + if name in seen_names: + raise ConfigError(f"{ctx}: duplicate signal name {name!r}") + seen_names.add(name) + + if direction not in ("input", "output"): + raise ConfigError( + f"{ctx}: direction must be 'input' or 'output', got {direction!r}" + ) + + dev = device_map.get(dev_id) + if dev is None: + raise ConfigError(f"{ctx}: references unknown device {dev_id!r}") + + mod = dev.module_for_slot(slot) + if mod is None: + slots = [m.slot for m in dev.modules] + raise ConfigError( + f"{ctx}: device {dev_id!r} has no module at slot {slot}. " + f"Available slots: {slots}" + ) + + if mod.module_type.direction != direction: + raise ConfigError( + f"{ctx}: signal direction {direction!r} does not match " + f"module type {mod.type!r} which is {mod.module_type.direction!r}" + ) + + if point < 1 or point > mod.points: + raise ConfigError( + f"{ctx}: point {point} out of range for module at slot {slot} " + f"(module has {mod.points} points, 1-based)" + ) + + # Parse default_state (digital outputs only) + default_state: bool | None = None + if "default_state" in raw: + if not mod.module_type.is_digital or direction != "output": + raise ConfigError( + f"{ctx}: default_state is only valid for digital output signals" + ) + default_state = _parse_bool(raw["default_state"], ctx + ".default_state") + + # Parse default_value (analog outputs only) + default_value: int | None = None + if "default_value" in raw: + if not mod.module_type.is_analog or direction != "output": + raise ConfigError( + f"{ctx}: default_value is only valid for analog output signals" + ) + default_value = int(raw["default_value"]) + + # Modbus address = module's base address + (point - 1) + modbus_address = mod.modbus_address + (point - 1) + + signals.append(LogicalIO( + name=name, + device=dev_id, + slot=slot, + point=point, + direction=direction, + category=mod.category, + modbus_space=mod.modbus_space, + value_type=mod.module_type.value_type, + default_state=default_state, + default_value=default_value, + modbus_address=modbus_address, + device_ref=dev, + module_ref=mod, + )) + + return signals + + +def _parse_sequences( + raw_list: list, signal_map: dict[str, LogicalIO] +) -> list[Sequence]: + if not isinstance(raw_list, list): + raise ConfigError("'sequences' must be a list") + + sequences: list[Sequence] = [] + seen_names: set[str] = set() + + for i, raw in enumerate(raw_list): + ctx = f"sequences[{i}]" + if not isinstance(raw, dict): + raise ConfigError(f"{ctx}: must be a mapping") + + name = _require_str(raw, "name", ctx) + description = raw.get("description", "") + + if name in seen_names: + raise ConfigError(f"{ctx}: duplicate sequence name {name!r}") + seen_names.add(name) + + steps = _parse_steps(raw.get("steps") or [], signal_map, ctx) + sequences.append(Sequence(name=name, description=description, steps=steps)) + + return sequences + + +def _parse_steps( + raw_list: list, signal_map: dict[str, LogicalIO], parent_ctx: str +) -> list[SequenceStep]: + if not isinstance(raw_list, list): + raise ConfigError(f"{parent_ctx}.steps: must be a list") + + steps: list[SequenceStep] = [] + + for i, raw in enumerate(raw_list): + ctx = f"{parent_ctx}.steps[{i}]" + if not isinstance(raw, dict): + raise ConfigError(f"{ctx}: must be a mapping") + + t_ms = int(_require(raw, "t_ms", ctx)) + action = _require_str(raw, "action", ctx) + signal = _require_str(raw, "signal", ctx) + + if t_ms < 0: + raise ConfigError(f"{ctx}: t_ms must be >= 0, got {t_ms}") + + if action not in ("set_output", "check_input", "wait_input"): + raise ConfigError( + f"{ctx}: action must be 'set_output', 'check_input', or 'wait_input'," + f" got {action!r}" + ) + + sig = signal_map.get(signal) + if sig is None: + raise ConfigError(f"{ctx}: references unknown signal {signal!r}") + + step = SequenceStep(t_ms=t_ms, action=action, signal=signal) + + if action == "set_output": + if sig.direction != "output": + raise ConfigError( + f"{ctx}: set_output action used on signal {signal!r} " + f"which is an input — use check_input instead" + ) + if sig.value_type == "bool": + if "state" not in raw: + raise ConfigError( + f"{ctx}: set_output step for digital signal requires 'state'" + ) + step.state = _parse_bool(raw["state"], ctx + ".state") + else: + if "value" not in raw: + raise ConfigError( + f"{ctx}: set_output step for analog signal requires 'value'" + ) + step.value = int(raw["value"]) + + elif action in ("check_input", "wait_input"): + if sig.value_type == "bool": + if "expected" not in raw: + raise ConfigError( + f"{ctx}: {action} step for digital signal requires 'expected'" + ) + step.expected = _parse_bool(raw["expected"], ctx + ".expected") + else: + if "expected_value" not in raw: + raise ConfigError( + f"{ctx}: {action} step for analog signal requires " + f"'expected_value'" + ) + step.expected_value = int(raw["expected_value"]) + if "tolerance" in raw: + step.tolerance = int(raw["tolerance"]) + else: + step.tolerance = 0 + + if action == "wait_input": + if "timeout_ms" not in raw: + raise ConfigError( + f"{ctx}: wait_input step requires 'timeout_ms' field" + ) + step.timeout_ms = int(raw["timeout_ms"]) + if step.timeout_ms <= 0: + raise ConfigError( + f"{ctx}: timeout_ms must be > 0, got {step.timeout_ms}" + ) + + steps.append(step) + + # Sort by t_ms so steps need not be in order in the YAML + steps.sort(key=lambda s: s.t_ms) + return steps + + +# --------------------------------------------------------------------------- +# Tiny helpers +# --------------------------------------------------------------------------- + +def _require(d: dict, key: str, ctx: str) -> object: + if key not in d: + raise ConfigError(f"{ctx}: missing required field '{key}'") + return d[key] + + +def _require_str(d: dict, key: str, ctx: str) -> str: + val = _require(d, key, ctx) + if not isinstance(val, str): + raise ConfigError(f"{ctx}.{key}: expected string, got {type(val).__name__}") + return val diff --git a/arnold/module_types.py b/arnold/module_types.py new file mode 100644 index 0000000..ac2eb45 --- /dev/null +++ b/arnold/module_types.py @@ -0,0 +1,289 @@ +""" +arnold/module_types.py — Terminator I/O module type registry. + +Every T1H (full-size) and T1K (compact) module that the EBC100 supports +is catalogued here as a frozen ModuleType dataclass. + +Categories +---------- + digital_input FC02 (read discrete inputs) — coil address space + digital_output FC01/FC05/FC15 (read/write coils) — coil address space + relay_output Same Modbus behaviour as digital_output, relay contacts + analog_input FC04 (read input registers) — register address space + analog_output FC03/FC06/FC16 (read/write holding registers) — register space + temperature_input FC04 (read input registers) — register address space + +Address spaces +-------------- + The EBC100 maintains TWO independent flat address spaces: + coil space — digital modules only, 1-bit per point + register space — analog + temperature modules, 16-bit per channel + + Digital and analog modules do NOT interfere with each other's address + offsets. A digital-input module in slot 1 advances coil_offset but + has zero effect on register_offset, and vice-versa. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +# --------------------------------------------------------------------------- +# Category type +# --------------------------------------------------------------------------- + +Category = Literal[ + "digital_input", + "digital_output", + "relay_output", + "analog_input", + "analog_output", + "temperature_input", +] + + +# --------------------------------------------------------------------------- +# ModuleType dataclass +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ModuleType: + part_number: str # e.g. "T1H-08TDS" + series: Literal["T1H", "T1K"] # housing form factor + category: Category + points: int # I/O count (channels for analog) + signal_type: str # human description of signal + resolution_bits: int | None = None # None for digital, 12/15/16 for analog + range_options: tuple[str, ...] = () # e.g. ("0-20mA","4-20mA","0-10V","±10V") + max_current_per_point: str = "" # e.g. "0.3A", "1A@250VAC" + + # -- Derived properties -------------------------------------------------- + + @property + def is_digital(self) -> bool: + return self.category in ( + "digital_input", "digital_output", "relay_output", + ) + + @property + def is_analog(self) -> bool: + return self.category in ( + "analog_input", "analog_output", "temperature_input", + ) + + @property + def direction(self) -> Literal["input", "output"]: + if self.category in ("digital_input", "analog_input", "temperature_input"): + return "input" + return "output" + + @property + def modbus_space(self) -> Literal["coil", "register"]: + """Which EBC100 address space this module lives in.""" + return "coil" if self.is_digital else "register" + + @property + def value_type(self) -> Literal["bool", "int"]: + """Python type of per-point values: bool for digital, int for analog.""" + return "bool" if self.is_digital else "int" + + +# --------------------------------------------------------------------------- +# Full registry +# --------------------------------------------------------------------------- + +_ALL_MODULES: list[ModuleType] = [ + # ══════════════════════════════════════════════════════════════════════════ + # DIGITAL INPUTS — T1H + # ══════════════════════════════════════════════════════════════════════════ + ModuleType("T1H-08TDS", "T1H", "digital_input", 8, + "24VDC sinking (NPN) input, 4.1mA/pt"), + ModuleType("T1H-08ND3", "T1H", "digital_input", 8, + "24VDC sinking/sourcing input"), + ModuleType("T1H-08ND3P", "T1H", "digital_input", 8, + "24VDC sourcing (PNP) input"), + ModuleType("T1H-16ND3", "T1H", "digital_input", 16, + "24VDC sinking/sourcing input"), + ModuleType("T1H-08NA", "T1H", "digital_input", 8, + "120VAC input"), + ModuleType("T1H-16NA", "T1H", "digital_input", 16, + "120VAC input"), + + # DIGITAL INPUTS — T1K + ModuleType("T1K-08TDS", "T1K", "digital_input", 8, + "24VDC sinking (NPN) input, 4.1mA/pt"), + ModuleType("T1K-08ND3", "T1K", "digital_input", 8, + "24VDC sinking/sourcing input"), + ModuleType("T1K-16ND3", "T1K", "digital_input", 16, + "24VDC sinking/sourcing input"), + + # ══════════════════════════════════════════════════════════════════════════ + # DIGITAL OUTPUTS — T1H + # ══════════════════════════════════════════════════════════════════════════ + ModuleType("T1H-08TD1", "T1H", "digital_output", 8, + "24VDC sourcing transistor output", + max_current_per_point="0.3A"), + ModuleType("T1H-08TD2", "T1H", "digital_output", 8, + "12-24VDC sinking transistor output", + max_current_per_point="0.3A"), + ModuleType("T1H-16TD1", "T1H", "digital_output", 16, + "24VDC sourcing transistor output", + max_current_per_point="0.1A"), + ModuleType("T1H-16TD2", "T1H", "digital_output", 16, + "12-24VDC sinking transistor output", + max_current_per_point="0.1A"), + ModuleType("T1H-08TA", "T1H", "digital_output", 8, + "120/240VAC triac output", + max_current_per_point="0.5A"), + + # DIGITAL OUTPUTS — T1K + ModuleType("T1K-08TD1", "T1K", "digital_output", 8, + "24VDC sourcing transistor output", + max_current_per_point="0.3A"), + ModuleType("T1K-08TD2", "T1K", "digital_output", 8, + "12-24VDC sinking transistor output", + max_current_per_point="0.3A"), + ModuleType("T1K-16TD1", "T1K", "digital_output", 16, + "24VDC sourcing transistor output", + max_current_per_point="0.1A"), + ModuleType("T1K-16TD2", "T1K", "digital_output", 16, + "12-24VDC sinking transistor output", + max_current_per_point="0.1A"), + ModuleType("T1K-16TD2-1","T1K", "digital_output", 16, + "12-24VDC sourcing transistor output", + max_current_per_point="0.1A"), + ModuleType("T1K-08TA", "T1K", "digital_output", 8, + "120/240VAC triac output", + max_current_per_point="0.5A"), + + # ══════════════════════════════════════════════════════════════════════════ + # RELAY OUTPUTS + # ══════════════════════════════════════════════════════════════════════════ + ModuleType("T1H-08TRS", "T1H", "relay_output", 8, + "Relay output (Form A SPST-NO)", + max_current_per_point="1A@250VAC"), + ModuleType("T1H-16TRS", "T1H", "relay_output", 16, + "Relay output (Form A SPST-NO)", + max_current_per_point="1A@250VAC"), + ModuleType("T1H-16TRS2", "T1H", "relay_output", 16, + "Relay output (Form A SPST-NO)", + max_current_per_point="2A@250VAC"), + ModuleType("T1K-08TRS", "T1K", "relay_output", 8, + "Relay output (Form A SPST-NO)", + max_current_per_point="1A@250VAC"), + + # ══════════════════════════════════════════════════════════════════════════ + # ANALOG INPUTS — T1H + # ══════════════════════════════════════════════════════════════════════════ + ModuleType("T1H-08AD-1", "T1H", "analog_input", 8, + "Voltage/current analog input, 12-bit", + resolution_bits=12, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V", "0-5V", "±5V")), + ModuleType("T1H-08AD-2", "T1H", "analog_input", 8, + "Voltage/current analog input, 15-bit", + resolution_bits=15, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V", "0-5V", "±5V")), + ModuleType("T1H-16AD-1", "T1H", "analog_input", 16, + "Voltage/current analog input, 12-bit", + resolution_bits=12, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V")), + ModuleType("T1H-16AD-2", "T1H", "analog_input", 16, + "Voltage/current analog input, 15-bit", + resolution_bits=15, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V")), + + # ANALOG INPUTS — T1K + ModuleType("T1K-08AD-1", "T1K", "analog_input", 8, + "Voltage/current analog input, 12-bit", + resolution_bits=12, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V", "0-5V", "±5V")), + ModuleType("T1K-08AD-2", "T1K", "analog_input", 8, + "Voltage/current analog input, 15-bit", + resolution_bits=15, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V", "0-5V", "±5V")), + + # ══════════════════════════════════════════════════════════════════════════ + # ANALOG OUTPUTS — T1H + # ══════════════════════════════════════════════════════════════════════════ + ModuleType("T1H-04DA-1", "T1H", "analog_output", 4, + "Voltage/current analog output, 12-bit", + resolution_bits=12, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V")), + ModuleType("T1H-04DA-2", "T1H", "analog_output", 4, + "Voltage/current analog output, 15-bit", + resolution_bits=15, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V")), + ModuleType("T1H-08DA-1", "T1H", "analog_output", 8, + "Voltage/current analog output, 12-bit", + resolution_bits=12, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V")), + ModuleType("T1H-08DA-2", "T1H", "analog_output", 8, + "Voltage/current analog output, 15-bit", + resolution_bits=15, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V")), + ModuleType("T1H-16DA-1", "T1H", "analog_output", 16, + "Voltage/current analog output, 12-bit", + resolution_bits=12, + range_options=("0-20mA", "4-20mA", "0-10V")), + ModuleType("T1H-16DA-2", "T1H", "analog_output", 16, + "Voltage/current analog output, 15-bit", + resolution_bits=15, + range_options=("0-20mA", "4-20mA", "0-10V")), + + # ANALOG OUTPUTS — T1K + ModuleType("T1K-04DA-1", "T1K", "analog_output", 4, + "Voltage/current analog output, 12-bit", + resolution_bits=12, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V")), + ModuleType("T1K-04DA-2", "T1K", "analog_output", 4, + "Voltage/current analog output, 15-bit", + resolution_bits=15, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V")), + ModuleType("T1K-08DA-1", "T1K", "analog_output", 8, + "Voltage/current analog output, 12-bit", + resolution_bits=12, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V")), + ModuleType("T1K-08DA-2", "T1K", "analog_output", 8, + "Voltage/current analog output, 15-bit", + resolution_bits=15, + range_options=("0-20mA", "4-20mA", "0-10V", "±10V")), + + # ══════════════════════════════════════════════════════════════════════════ + # TEMPERATURE INPUTS + # ══════════════════════════════════════════════════════════════════════════ + ModuleType("T1H-08THM", "T1H", "temperature_input", 8, + "Thermocouple input (J/K/E/T/R/S/N/B)", + resolution_bits=16, + range_options=("type J", "type K", "type E", "type T", + "type R", "type S", "type N", "type B")), + ModuleType("T1H-04RTD", "T1H", "temperature_input", 4, + "RTD input (Pt100/Pt1000/Ni120)", + resolution_bits=16, + range_options=("Pt100", "Pt1000", "Ni120")), + ModuleType("T1K-08THM", "T1K", "temperature_input", 8, + "Thermocouple input (J/K/E/T/R/S/N/B)", + resolution_bits=16, + range_options=("type J", "type K", "type E", "type T", + "type R", "type S", "type N", "type B")), + ModuleType("T1K-04RTD", "T1K", "temperature_input", 4, + "RTD input (Pt100/Pt1000/Ni120)", + resolution_bits=16, + range_options=("Pt100", "Pt1000", "Ni120")), +] + + +# Build the lookup dict +MODULE_REGISTRY: dict[str, ModuleType] = {m.part_number: m for m in _ALL_MODULES} + + +def get_module_type(part_number: str) -> ModuleType: + """Look up a module type by part number. Raises KeyError with helpful message.""" + try: + return MODULE_REGISTRY[part_number] + except KeyError: + known = ", ".join(sorted(MODULE_REGISTRY)) + raise KeyError( + f"Unknown module type {part_number!r}. " + f"Known types: {known}" + ) from None diff --git a/arnold/sequencer.py b/arnold/sequencer.py new file mode 100644 index 0000000..6e82fde --- /dev/null +++ b/arnold/sequencer.py @@ -0,0 +1,494 @@ +""" +arnold/sequencer.py — Sequence execution engine. + +Design: + - One sequence can run at a time (enforced by a mutex). + - Runs asynchronously in a worker thread; returns a run_id immediately. + - Caller polls GET /runs/{run_id} for result. + - Absolute timing: each step fires at t_start + step.t_ms (monotonic clock). + - check_input: reads from IOState cache (fast-poll value), instant check. + - set_output: calls IODriver.write_output() directly. + - On step failure: remaining steps are skipped, outputs are NOT auto-reset + (caller is responsible for safety; a future "on_abort" hook could be added). +""" + +from __future__ import annotations + +import json +import logging +import threading +import time +import uuid +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Literal + +if TYPE_CHECKING: + from .config import Config, Sequence, SequenceStep + from .terminator_io import IORegistry + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Run result dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class StepResult: + step_index: int + t_ms: int + action: str + signal: str + success: bool + detail: str = "" # human-readable description + actual: bool | int | None = None # for check_input / wait_input + expected: bool | int | None = None + + +@dataclass +class RunResult: + run_id: str + sequence_name: str + status: Literal["pending", "running", "success", "failed", "error"] + started_at: str = "" # ISO8601 + finished_at: str = "" + duration_ms: int = 0 + steps_completed: int = 0 + total_steps: int = 0 + current_step_index: int = -1 # index of the step currently executing (-1 = none) + failed_step: StepResult | None = None + error_message: str = "" + + def to_dict(self) -> dict: + d = asdict(self) + return d + + +# --------------------------------------------------------------------------- +# Run log (JSON-lines file) +# --------------------------------------------------------------------------- + +class RunLog: + def __init__(self, path: Path) -> None: + self._path = path + self._lock = threading.Lock() + + def append(self, result: RunResult) -> None: + with self._lock: + with open(self._path, "a") as f: + f.write(json.dumps(result.to_dict()) + "\n") + + def tail(self, n: int = 50) -> list[dict]: + """Return the last n entries.""" + if not self._path.exists(): + return [] + with self._lock: + lines = self._path.read_text().splitlines() + entries = [] + for line in lines[-n:]: + line = line.strip() + if line: + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + pass + return list(reversed(entries)) # most recent first + + +# --------------------------------------------------------------------------- +# Sequencer +# --------------------------------------------------------------------------- + +class Sequencer: + def __init__( + self, + config: "Config", + registry: "IORegistry", + log_path: Path, + on_output_write: Callable[[str, bool | int], None] | None = None, + ) -> None: + self._config = config + self._registry = registry + self._run_log = RunLog(log_path) + + # Optional callback fired after every successful set_output step. + # Signature: on_output_write(signal_name: str, value: bool | int) + self._on_output_write = on_output_write + + # One-at-a-time enforcement + self._run_lock = threading.Lock() + self._active_id: str | None = None + + # Result store: run_id -> RunResult + self._results_lock = threading.Lock() + self._results: dict[str, RunResult] = {} + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def start(self, sequence_name: str) -> tuple[str, bool]: + """ + Launch sequence_name in a background thread. + + Returns (run_id, started). + started=False means another sequence is already running (caller + should return HTTP 409). + """ + seq = self._config.sequence(sequence_name) + if seq is None: + raise ValueError(f"Unknown sequence: {sequence_name!r}") + + # Try to acquire run lock non-blocking + if not self._run_lock.acquire(blocking=False): + return ("", False) + + run_id = str(uuid.uuid4()) + result = RunResult( + run_id=run_id, + sequence_name=sequence_name, + status="pending", + total_steps=len(seq.steps), + ) + with self._results_lock: + self._results[run_id] = result + self._active_id = run_id + + t = threading.Thread( + target=self._run_thread, + args=(seq, run_id), + name=f"seq-{sequence_name}-{run_id[:8]}", + daemon=True, + ) + t.start() + return (run_id, True) + + def get_result(self, run_id: str) -> RunResult | None: + with self._results_lock: + return self._results.get(run_id) + + def active_run_id(self) -> str | None: + with self._results_lock: + return self._active_id + + def recent_runs(self, n: int = 50) -> list[dict]: + return self._run_log.tail(n) + + # ------------------------------------------------------------------ + # Execution thread + # ------------------------------------------------------------------ + + def _run_thread(self, seq: "Sequence", run_id: str) -> None: + started_at = datetime.now(timezone.utc) + t_start = time.monotonic() + + self._update_result(run_id, status="running", + started_at=started_at.isoformat()) + + log.info("Sequence %r started run_id=%s steps=%d", + seq.name, run_id, len(seq.steps)) + + failed_step: StepResult | None = None + steps_completed = 0 + + try: + for i, step in enumerate(seq.steps): + # Mark which step we're about to execute + self._update_result(run_id, current_step_index=i) + + # Wait until absolute time t_ms from start + target = t_start + step.t_ms / 1000.0 + now = time.monotonic() + if target > now: + time.sleep(target - now) + + ok, step_result = self._execute_step(i, step) + + if not ok: + steps_completed = i + failed_step = step_result + log.warning( + "Sequence %r FAILED at step %d (%s %s): %s", + seq.name, i, step.action, step.signal, step_result.detail, + ) + break + + steps_completed = i + 1 + self._update_result(run_id, steps_completed=steps_completed) + log.debug("Step %d OK: %s %s", i, step.action, step.signal) + + except Exception as exc: + log.exception("Sequence %r raised exception: %s", seq.name, exc) + finished_at = datetime.now(timezone.utc) + duration_ms = int((time.monotonic() - t_start) * 1000) + result = self._update_result( + run_id, + status="error", + finished_at=finished_at.isoformat(), + duration_ms=duration_ms, + steps_completed=steps_completed, + current_step_index=-1, + error_message=str(exc), + ) + self._run_log.append(result) + self._run_lock.release() + with self._results_lock: + self._active_id = None + return + + finished_at = datetime.now(timezone.utc) + duration_ms = int((time.monotonic() - t_start) * 1000) + + if failed_step: + status = "failed" + else: + status = "success" + steps_completed = len(seq.steps) + + result = self._update_result( + run_id, + status=status, + finished_at=finished_at.isoformat(), + duration_ms=duration_ms, + steps_completed=steps_completed, + current_step_index=-1, + failed_step=failed_step, + ) + + self._run_log.append(result) + self._run_lock.release() + with self._results_lock: + self._active_id = None + + log.info( + "Sequence %r %s run_id=%s duration=%dms steps=%d/%d", + seq.name, status.upper(), run_id, duration_ms, + steps_completed, len(seq.steps), + ) + + # ------------------------------------------------------------------ + # Step execution + # ------------------------------------------------------------------ + + def _execute_step(self, index: int, step: "SequenceStep") -> tuple[bool, StepResult]: + sig = self._config.signal(step.signal) + if sig is None: + # Should never happen — validated at load time + sr = StepResult(index, step.t_ms, step.action, step.signal, + False, f"Unknown signal {step.signal!r}") + return False, sr + + if step.action == "set_output": + return self._set_output(index, step, sig) + elif step.action == "check_input": + return self._check_input(index, step, sig) + elif step.action == "wait_input": + return self._wait_input(index, step, sig) + else: + sr = StepResult(index, step.t_ms, step.action, step.signal, + False, f"Unknown action {step.action!r}") + return False, sr + + # -- set_output ---------------------------------------------------- + + def _set_output( + self, index: int, step: "SequenceStep", sig: Any + ) -> tuple[bool, StepResult]: + driver = self._registry.driver(sig.device) + if driver is None: + sr = StepResult(index, step.t_ms, step.action, step.signal, + False, f"No driver for device {sig.device!r}") + return False, sr + + if sig.value_type == "int": + # Analog output — FC06 (single register write) + write_val: bool | int = int(step.value or 0) + ok = driver.write_register(sig.modbus_address, write_val) + detail_ok = f"Set {step.signal}={write_val}" + detail_err = f"Register write failed for {step.signal}" + else: + # Digital output — FC05 (single coil write) + write_val = bool(step.state) + ok = driver.write_output(sig.modbus_address, write_val) + detail_ok = f"Set {step.signal}={'ON' if write_val else 'OFF'}" + detail_err = f"Coil write failed for {step.signal}" + + if ok and self._on_output_write: + try: + self._on_output_write(step.signal, write_val) + except Exception: + pass + + sr = StepResult( + step_index=index, + t_ms=step.t_ms, + action=step.action, + signal=step.signal, + success=ok, + detail=detail_ok if ok else detail_err, + ) + return ok, sr + + # -- check_input --------------------------------------------------- + + def _check_input( + self, index: int, step: "SequenceStep", sig: Any + ) -> tuple[bool, StepResult]: + actual = self._registry.get_value(step.signal) + stale = self._registry.is_stale(step.signal) + + if stale or actual is None: + sr = StepResult( + step_index=index, + t_ms=step.t_ms, + action=step.action, + signal=step.signal, + success=False, + detail=f"Signal {step.signal!r} is stale or not yet read", + actual=actual, + expected=self._expected_for_step(step, sig), + ) + return False, sr + + ok, expected_display = self._compare(actual, step, sig) + if sig.value_type == "int": + detail = ( + f"Check {step.signal}: expected={expected_display} " + f"actual={actual}" + ) + else: + detail = ( + f"Check {step.signal}: expected={'ON' if step.expected else 'OFF'} " + f"actual={'ON' if actual else 'OFF'}" + ) + sr = StepResult( + step_index=index, + t_ms=step.t_ms, + action=step.action, + signal=step.signal, + success=ok, + detail=detail, + actual=actual, + expected=self._expected_for_step(step, sig), + ) + return ok, sr + + # -- wait_input ---------------------------------------------------- + + def _wait_input( + self, index: int, step: "SequenceStep", sig: Any + ) -> tuple[bool, StepResult]: + """ + Poll the signal cache until the expected value is seen or timeout expires. + + Polls every 50 ms. Supports both digital (exact bool match) and + analog (abs(actual - expected_value) <= tolerance) comparisons. + """ + timeout_s = (step.timeout_ms or 0) / 1000.0 + deadline = time.monotonic() + timeout_s + poll_interval = 0.05 # 50 ms + + exp_display = self._expected_display(step, sig) + + while True: + actual = self._registry.get_value(step.signal) + stale = self._registry.is_stale(step.signal) + + if not stale and actual is not None: + ok, _ = self._compare(actual, step, sig) + if ok: + sr = StepResult( + step_index=index, + t_ms=step.t_ms, + action=step.action, + signal=step.signal, + success=True, + detail=f"Wait {step.signal}=={exp_display}: condition met", + actual=actual, + expected=self._expected_for_step(step, sig), + ) + return True, sr + + if time.monotonic() >= deadline: + if stale or actual is None: + act_str = "stale" + elif sig.value_type == "int": + act_str = str(actual) + else: + act_str = "ON" if actual else "OFF" + sr = StepResult( + step_index=index, + t_ms=step.t_ms, + action=step.action, + signal=step.signal, + success=False, + detail=( + f"Wait {step.signal}=={exp_display}: " + f"timeout after {step.timeout_ms} ms (actual={act_str})" + ), + actual=actual, + expected=self._expected_for_step(step, sig), + ) + return False, sr + + time.sleep(poll_interval) + + # -- Comparison helpers -------------------------------------------- + + @staticmethod + def _compare( + actual: bool | int, + step: "SequenceStep", + sig: Any, + ) -> tuple[bool, str]: + """ + Compare actual value against step expectation. + + Returns (match: bool, expected_display: str). + """ + if sig.value_type == "int": + # Analog comparison with tolerance + expected = step.expected_value if step.expected_value is not None else 0 + tolerance = step.tolerance if step.tolerance is not None else 0 + ok = abs(int(actual) - expected) <= tolerance + if tolerance > 0: + display = f"{expected}±{tolerance}" + else: + display = str(expected) + return ok, display + else: + # Digital: exact bool match + ok = (actual == step.expected) + display = "ON" if step.expected else "OFF" + return ok, display + + @staticmethod + def _expected_for_step(step: "SequenceStep", sig: Any) -> bool | int | None: + """Return the expected value in the appropriate type for StepResult.""" + if sig.value_type == "int": + return step.expected_value + return step.expected + + @staticmethod + def _expected_display(step: "SequenceStep", sig: Any) -> str: + """Human-readable expected value string.""" + if sig.value_type == "int": + expected = step.expected_value if step.expected_value is not None else 0 + tolerance = step.tolerance if step.tolerance is not None else 0 + if tolerance > 0: + return f"{expected}±{tolerance}" + return str(expected) + return "ON" if step.expected else "OFF" + + # ------------------------------------------------------------------ + # Internal result update + # ------------------------------------------------------------------ + + def _update_result(self, run_id: str, **kwargs) -> RunResult: + with self._results_lock: + result = self._results[run_id] + for k, v in kwargs.items(): + setattr(result, k, v) + return result diff --git a/arnold/terminator_io.py b/arnold/terminator_io.py new file mode 100644 index 0000000..82bfe15 --- /dev/null +++ b/arnold/terminator_io.py @@ -0,0 +1,663 @@ +""" +arnold/terminator_io.py — AutomationDirect Terminator I/O driver. + +Encapsulates everything that touches a physical T1H-EBC100 controller: + - Modbus TCP connection management (pymodbus, auto-reconnect) + - Signal state cache (thread-safe) + - Background fast-poll thread (reads both coils and registers each cycle) + +Key hardware quirks documented here: + - The EBC100 uses a UNIFIED flat coil address space across all digital + modules in physical slot order. FC02 (read discrete inputs) and + FC01/FC05/FC15 (read/write coils) share the same sequential offsets. + If slot 1 and slot 2 are 8-pt input modules (addresses 0-7, 8-15), + a 16-pt output module in slot 3 starts at coil address 16 — NOT 0. + + - The EBC100 maintains TWO independent flat address spaces: + coil space (1-bit) — digital modules: FC01/FC02/FC05/FC15 + register space (16-bit) — analog + temperature: FC03/FC04/FC06/FC16 + A digital module advances only the coil offset; an analog module + advances only the register offset. They do not interfere. + + - FC02 (read discrete inputs) returns input bits starting at address 0. + Because input modules always appear first in the unified coil scheme, + the FC02 bit index equals modbus_address for every digital input signal. + + - FC04 (read input registers) returns 16-bit values for analog/temperature + input modules, starting at register address 0 in the register space. + + - The EBC100 never raises Modbus exception code 2 (illegal address) for + out-of-range reads — it silently returns zeros. Module presence cannot + be auto-detected via protocol errors; use the config 'modules' list. + + - The EBC100 responds to any Modbus unit/slave ID over TCP — the unit_id + field is echoed back but not used for routing. Set it to 1 (default). + + - FC05 write_coil echoes back True for any address, even unmapped ones. + There is no write-error feedback for out-of-range output addresses. + + - The device has no unsolicited push capability. Polling is mandatory. + +Public API +---------- + TerminatorIO(device: DeviceConfig) + .connect() -> bool + .disconnect() + .read_inputs() -> list[bool] | None # bulk FC02, digital inputs + .read_registers(address, count) -> list[int] | None # bulk FC04, analog inputs + .write_output(address, value) -> bool # FC05 single coil + .write_outputs(address, values) -> bool # FC15 multiple coils + .write_register(address, value) -> bool # FC06 single register + .write_registers(address, values) -> bool # FC16 multiple registers + .connected: bool + .status() -> dict + + SignalState dataclass: name, value (bool|int), updated_at, stale + IORegistry(config) multi-device coordinator + .start() connect + start all poll threads + .stop() stop all poll threads + disconnect + .get(signal) -> SignalState | None + .get_value(signal) -> bool | int | None + .snapshot() -> dict[str, SignalState] + .poll_stats() -> list[dict] + .driver_status() -> list[dict] +""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pymodbus.client import ModbusTcpClient +from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ExceptionResponse + +if TYPE_CHECKING: + from .config import Config, DeviceConfig, LogicalIO + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Signal state +# --------------------------------------------------------------------------- + +@dataclass +class SignalState: + name: str + value: bool | int + updated_at: float # time.monotonic() + stale: bool = False # True when the last poll for this device failed + + +# --------------------------------------------------------------------------- +# TerminatorIO — one instance per physical EBC100 controller +# --------------------------------------------------------------------------- + +class TerminatorIO: + """ + Modbus TCP driver for a single T1H-EBC100 controller. + + Thread-safe: all public methods acquire an internal lock. The poll + thread holds the lock only for the duration of each FC02 call, so + write_output() will block at most one poll cycle (~50 ms). + """ + + def __init__(self, device: "DeviceConfig") -> None: + self.device = device + self._lock = threading.Lock() + self._client: ModbusTcpClient | None = None + self._connected = False + self._connect_attempts = 0 + self._last_connect_error = "" + + # ------------------------------------------------------------------ + # Connection + # ------------------------------------------------------------------ + + def connect(self) -> bool: + """Open the Modbus TCP connection. Returns True on success.""" + with self._lock: + return self._connect_locked() + + def _connect_locked(self) -> bool: + if self._client is not None: + try: + self._client.close() + except Exception: + pass + + self._client = ModbusTcpClient( + host=self.device.host, + port=self.device.port, + timeout=2, + retries=1, + ) + self._connect_attempts += 1 + ok = self._client.connect() + self._connected = ok + if ok: + log.info("Connected to %s (%s:%d)", + self.device.id, self.device.host, self.device.port) + else: + self._last_connect_error = ( + f"TCP connect failed to {self.device.host}:{self.device.port}" + ) + log.warning("Could not connect to %s: %s", + self.device.id, self._last_connect_error) + return ok + + def disconnect(self) -> None: + with self._lock: + if self._client: + try: + self._client.close() + except Exception: + pass + self._connected = False + self._client = None + + @property + def connected(self) -> bool: + return self._connected + + # ------------------------------------------------------------------ + # Read inputs — single bulk FC02 request for all input modules + # ------------------------------------------------------------------ + + def read_inputs(self) -> list[bool] | None: + """ + Read all discrete input points in one FC02 request. + + Returns a flat list of bool ordered by slot then point (matching + the unified address scheme), or None on comms error. + + FC02 returns input bits starting at address 0. Because input modules + are always at lower slot numbers than output modules (enforced by the + unified address scheme), the FC02 bit index equals modbus_address for + every input signal. + """ + total = self.device.total_input_points() + if total == 0: + return [] + with self._lock: + return self._fc02_locked(address=0, count=total) + + def _fc02_locked(self, address: int, count: int) -> list[bool] | None: + for attempt in range(2): + if not self._connected: + if not self._connect_locked(): + return None + try: + rr = self._client.read_discrete_inputs( + address=address, count=count, + device_id=self.device.unit_id, + ) + if rr.isError() or isinstance(rr, ExceptionResponse): + log.warning("%s FC02 error: %s", self.device.id, rr) + self._connected = False + continue + return list(rr.bits[:count]) + except (ModbusException, ConnectionError, OSError) as exc: + log.warning("%s read error (attempt %d): %s", + self.device.id, attempt + 1, exc) + self._connected = False + time.sleep(0.05) + return None + + # ------------------------------------------------------------------ + # Read analog input registers — single bulk FC04 request + # ------------------------------------------------------------------ + + def read_registers(self, address: int, count: int) -> list[int] | None: + """ + Read contiguous 16-bit input registers via FC04. + + Used for analog and temperature input modules whose signals live + in the register address space. Returns a list of raw int values + (0–65535), or None on comms error. + """ + if count == 0: + return [] + with self._lock: + return self._fc04_locked(address, count) + + def _fc04_locked(self, address: int, count: int) -> list[int] | None: + for attempt in range(2): + if not self._connected: + if not self._connect_locked(): + return None + try: + rr = self._client.read_input_registers( + address=address, count=count, + device_id=self.device.unit_id, + ) + if rr.isError() or isinstance(rr, ExceptionResponse): + log.warning("%s FC04 error: %s", self.device.id, rr) + self._connected = False + continue + return list(rr.registers[:count]) + except (ModbusException, ConnectionError, OSError) as exc: + log.warning("%s FC04 read error (attempt %d): %s", + self.device.id, attempt + 1, exc) + self._connected = False + time.sleep(0.05) + return None + + # ------------------------------------------------------------------ + # Write digital outputs + # ------------------------------------------------------------------ + + def write_output(self, address: int, value: bool) -> bool: + """ + Write a single coil via FC05. + + Address is the unified slot-order coil address (as stored in + LogicalIO.modbus_address). Returns True on success. + + Note: the EBC100 echoes True for any address — write errors for + out-of-range addresses are silent. Config validation prevents + invalid addresses at startup. + """ + with self._lock: + return self._fc05_locked(address, value) + + def _fc05_locked(self, address: int, value: bool) -> bool: + for attempt in range(2): + if not self._connected: + if not self._connect_locked(): + return False + try: + rr = self._client.write_coil( + address=address, value=value, + device_id=self.device.unit_id, + ) + if rr.isError() or isinstance(rr, ExceptionResponse): + log.warning("%s FC05 error addr=%d: %s", + self.device.id, address, rr) + self._connected = False + continue + log.debug("%s coil[%d] = %s", self.device.id, address, value) + return True + except (ModbusException, ConnectionError, OSError) as exc: + log.warning("%s write error (attempt %d): %s", + self.device.id, attempt + 1, exc) + self._connected = False + time.sleep(0.05) + return False + + def write_outputs(self, address: int, values: list[bool]) -> bool: + """Write multiple contiguous coils via FC15.""" + with self._lock: + for attempt in range(2): + if not self._connected: + if not self._connect_locked(): + return False + try: + rr = self._client.write_coils( + address=address, values=values, + device_id=self.device.unit_id, + ) + if rr.isError() or isinstance(rr, ExceptionResponse): + log.warning("%s FC15 error addr=%d: %s", + self.device.id, address, rr) + self._connected = False + continue + return True + except (ModbusException, ConnectionError, OSError) as exc: + log.warning("%s write_coils error (attempt %d): %s", + self.device.id, attempt + 1, exc) + self._connected = False + time.sleep(0.05) + return False + + # ------------------------------------------------------------------ + # Write analog outputs + # ------------------------------------------------------------------ + + def write_register(self, address: int, value: int) -> bool: + """ + Write a single 16-bit holding register via FC06. + + Address is the register-space address (as stored in + LogicalIO.modbus_address for analog output signals). + value is a raw 16-bit integer (0–65535). + """ + with self._lock: + return self._fc06_locked(address, value) + + def _fc06_locked(self, address: int, value: int) -> bool: + for attempt in range(2): + if not self._connected: + if not self._connect_locked(): + return False + try: + rr = self._client.write_register( + address=address, value=value, + device_id=self.device.unit_id, + ) + if rr.isError() or isinstance(rr, ExceptionResponse): + log.warning("%s FC06 error addr=%d: %s", + self.device.id, address, rr) + self._connected = False + continue + log.debug("%s reg[%d] = %d", self.device.id, address, value) + return True + except (ModbusException, ConnectionError, OSError) as exc: + log.warning("%s FC06 write error (attempt %d): %s", + self.device.id, attempt + 1, exc) + self._connected = False + time.sleep(0.05) + return False + + def write_registers(self, address: int, values: list[int]) -> bool: + """Write multiple contiguous 16-bit holding registers via FC16.""" + with self._lock: + for attempt in range(2): + if not self._connected: + if not self._connect_locked(): + return False + try: + rr = self._client.write_registers( + address=address, values=values, + device_id=self.device.unit_id, + ) + if rr.isError() or isinstance(rr, ExceptionResponse): + log.warning("%s FC16 error addr=%d: %s", + self.device.id, address, rr) + self._connected = False + continue + return True + except (ModbusException, ConnectionError, OSError) as exc: + log.warning("%s FC16 write error (attempt %d): %s", + self.device.id, attempt + 1, exc) + self._connected = False + time.sleep(0.05) + return False + + # ------------------------------------------------------------------ + # Status + # ------------------------------------------------------------------ + + def status(self) -> dict: + return { + "device_id": self.device.id, + "host": self.device.host, + "port": self.device.port, + "connected": self._connected, + "connect_attempts": self._connect_attempts, + "last_error": self._last_connect_error or None, + } + + +# --------------------------------------------------------------------------- +# _PollThread — internal; one per TerminatorIO instance +# --------------------------------------------------------------------------- + +class _PollThread(threading.Thread): + """ + Reads all input points from one EBC100 at poll_interval_ms, updates the + shared signal cache. Daemon thread — exits when the process does. + + Each poll cycle reads BOTH address spaces: + - FC02 (coil space): digital input signals → list[bool] + - FC04 (register space): analog/temperature input signals → list[int] + """ + + def __init__( + self, + driver: TerminatorIO, + digital_signals: list["LogicalIO"], # digital input signals, sorted by modbus_address + analog_signals: list["LogicalIO"], # analog/temp input signals, sorted by modbus_address + cache: dict[str, SignalState], + lock: threading.Lock, + ) -> None: + super().__init__(name=f"poll-{driver.device.id}", daemon=True) + self._driver = driver + self._digital_signals = digital_signals + self._analog_signals = analog_signals + self._cache = cache + self._lock = lock + + self._stop = threading.Event() + self.poll_count = 0 + self.error_count = 0 + self._achieved_hz: float = 0.0 + self._last_poll_ts: float | None = None + + @property + def _total_signals(self) -> int: + return len(self._digital_signals) + len(self._analog_signals) + + def stop(self) -> None: + self._stop.set() + + def run(self) -> None: + interval = self._driver.device.poll_interval_ms / 1000.0 + log.info("Poll thread started: %s %.0f ms interval %d digital + %d analog signals", + self._driver.device.id, + self._driver.device.poll_interval_ms, + len(self._digital_signals), + len(self._analog_signals)) + + self._driver.connect() + + rate_t0 = time.monotonic() + rate_polls = 0 + + while not self._stop.is_set(): + t0 = time.monotonic() + self._cycle() + + rate_polls += 1 + self.poll_count += 1 + elapsed = time.monotonic() - t0 + + # Update achieved rate every 5 s + window = time.monotonic() - rate_t0 + if window >= 5.0: + self._achieved_hz = rate_polls / window + log.debug("%s %.1f polls/s errors=%d", + self._driver.device.id, + self._achieved_hz, self.error_count) + rate_t0 = time.monotonic() + rate_polls = 0 + + wait = interval - elapsed + if wait > 0: + self._stop.wait(wait) + + log.info("Poll thread stopped: %s", self._driver.device.id) + self._driver.disconnect() + + def _cycle(self) -> None: + if not self._digital_signals and not self._analog_signals: + return + + had_error = False + updates: dict[str, SignalState] = {} + now = time.monotonic() + + # ── Digital inputs (FC02, coil space) ───────────────────────── + if self._digital_signals: + bits = self._driver.read_inputs() + if bits is None: + had_error = True + for sig in self._digital_signals: + existing = self._cache.get(sig.name) + updates[sig.name] = SignalState( + name=sig.name, + value=existing.value if existing else False, + updated_at=existing.updated_at if existing else now, + stale=True, + ) + else: + for sig in self._digital_signals: + if sig.modbus_address < len(bits): + updates[sig.name] = SignalState( + name=sig.name, + value=bool(bits[sig.modbus_address]), + updated_at=now, + stale=False, + ) + else: + log.warning("%s signal %r addr %d out of range (%d bits)", + self._driver.device.id, sig.name, + sig.modbus_address, len(bits)) + + # ── Analog / temperature inputs (FC04, register space) ──────── + if self._analog_signals: + total_regs = self._driver.device.total_analog_input_channels() + regs = self._driver.read_registers(address=0, count=total_regs) + if regs is None: + had_error = True + for sig in self._analog_signals: + existing = self._cache.get(sig.name) + updates[sig.name] = SignalState( + name=sig.name, + value=existing.value if existing else 0, + updated_at=existing.updated_at if existing else now, + stale=True, + ) + else: + for sig in self._analog_signals: + if sig.modbus_address < len(regs): + updates[sig.name] = SignalState( + name=sig.name, + value=int(regs[sig.modbus_address]), + updated_at=now, + stale=False, + ) + else: + log.warning("%s signal %r reg addr %d out of range (%d regs)", + self._driver.device.id, sig.name, + sig.modbus_address, len(regs)) + + if had_error: + self.error_count += 1 + + self._last_poll_ts = now + + with self._lock: + self._cache.update(updates) + + def stats(self) -> dict: + return { + "device_id": self._driver.device.id, + "poll_count": self.poll_count, + "error_count": self.error_count, + "achieved_hz": round(self._achieved_hz, 1), + "target_hz": round(1000 / self._driver.device.poll_interval_ms, 1), + "last_poll_ts": self._last_poll_ts, + "running": self.is_alive(), + } + + +# --------------------------------------------------------------------------- +# IORegistry — multi-device coordinator (replaces PollManager + driver dict) +# --------------------------------------------------------------------------- + +class IORegistry: + """ + Owns all TerminatorIO drivers and poll threads for the full config. + + Usage: + registry = IORegistry(config) + registry.start() # connect + begin polling + ... + val = registry.get_value("my_signal") + registry.stop() + """ + + def __init__(self, config: "Config") -> None: + self._config = config + self._cache: dict[str, SignalState] = {} + self._lock = threading.Lock() + + # Build one TerminatorIO + one _PollThread per device + self._drivers: dict[str, TerminatorIO] = {} + self._pollers: list[_PollThread] = [] + + for device in config.devices: + driver = TerminatorIO(device) + self._drivers[device.id] = driver + + # Partition input signals by address space + digital_inputs = sorted( + (s for s in config.logical_io + if s.device == device.id + and s.direction == "input" + and s.modbus_space == "coil"), + key=lambda s: s.modbus_address, + ) + analog_inputs = sorted( + (s for s in config.logical_io + if s.device == device.id + and s.direction == "input" + and s.modbus_space == "register"), + key=lambda s: s.modbus_address, + ) + poller = _PollThread( + driver, digital_inputs, analog_inputs, + self._cache, self._lock, + ) + self._pollers.append(poller) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def start(self) -> None: + """Start all poll threads (each connects its own driver on first cycle).""" + for p in self._pollers: + p.start() + + def stop(self) -> None: + """Stop all poll threads and disconnect all drivers.""" + for p in self._pollers: + p.stop() + for p in self._pollers: + p.join(timeout=3) + + # ------------------------------------------------------------------ + # Signal reads (used by sequencer + API) + # ------------------------------------------------------------------ + + def get(self, signal_name: str) -> SignalState | None: + with self._lock: + return self._cache.get(signal_name) + + def get_value(self, signal_name: str) -> bool | int | None: + with self._lock: + s = self._cache.get(signal_name) + return s.value if s is not None else None + + def is_stale(self, signal_name: str) -> bool: + with self._lock: + s = self._cache.get(signal_name) + return s.stale if s is not None else True + + def snapshot(self) -> dict[str, SignalState]: + """Shallow copy of the full signal cache.""" + with self._lock: + return dict(self._cache) + + # ------------------------------------------------------------------ + # Output writes (used by sequencer) + # ------------------------------------------------------------------ + + def driver(self, device_id: str) -> TerminatorIO | None: + return self._drivers.get(device_id) + + # ------------------------------------------------------------------ + # Status / stats + # ------------------------------------------------------------------ + + def driver_status(self) -> list[dict]: + return [d.status() for d in self._drivers.values()] + + def poll_stats(self) -> list[dict]: + return [p.stats() for p in self._pollers] diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..625e89c --- /dev/null +++ b/config.yaml @@ -0,0 +1,329 @@ +# arnold config.yaml — Terminator I/O server configuration +# ───────────────────────────────────────────────────────────────────────────── +# DEVICES +# Each device is a Terminator I/O EBC100 Ethernet base controller. +# Modules are listed in physical slot order (left-to-right after the EBC100). +# Supported module types: T1H-08TDS, T1H-08ND3, T1H-16ND3, T1H-08NA (inputs) +# T1H-08TD1, T1H-08TD2, T1H-16TD1, T1H-08TA, T1H-08TRS (outputs) +# ───────────────────────────────────────────────────────────────────────────── + +devices: + - id: ebc100_main + host: 192.168.3.202 + port: 502 + unit_id: 1 + poll_interval_ms: 50 # 20 Hz — well within EBC100 capability + modules: + - slot: 1 + type: T1H-08TDS # 8-point 24VDC sinking digital input + points: 8 + - slot: 2 + type: T1H-08TDS + points: 8 + - slot: 3 + type: T1H-08TDS + points: 8 + # ── Uncomment and adapt when output modules are added ────────────────── + # - slot: 4 + # type: T1H-08TD1 # 8-point 24VDC sourcing digital output + # points: 8 + + +# ───────────────────────────────────────────────────────────────────────────── +# LOGICAL I/O +# Give human-readable names to individual I/O points. +# device: must match a devices[].id above +# slot: physical slot number (1-based, matches modules list) +# point: point within that module (1-based, 1–8 for 8-pt modules) +# direction: must match the module type (input or output) +# ───────────────────────────────────────────────────────────────────────────── + +logical_io: + # ── Slot 1 — Module 1 inputs ─────────────────────────────────────────────── + - name: input_1_1 + device: ebc100_main + slot: 1 + point: 1 + direction: input + + - name: input_1_2 + device: ebc100_main + slot: 1 + point: 2 + direction: input + + - name: input_1_3 + device: ebc100_main + slot: 1 + point: 3 + direction: input + + - name: input_1_4 + device: ebc100_main + slot: 1 + point: 4 + direction: input + + - name: input_1_5 + device: ebc100_main + slot: 1 + point: 5 + direction: input + + - name: input_1_6 + device: ebc100_main + slot: 1 + point: 6 + direction: input + + - name: input_1_7 + device: ebc100_main + slot: 1 + point: 7 + direction: input + + - name: input_1_8 + device: ebc100_main + slot: 1 + point: 8 + direction: input + + # ── Slot 2 — Module 2 inputs ─────────────────────────────────────────────── + - name: input_2_1 + device: ebc100_main + slot: 2 + point: 1 + direction: input + + - name: input_2_2 + device: ebc100_main + slot: 2 + point: 2 + direction: input + + - name: input_2_3 + device: ebc100_main + slot: 2 + point: 3 + direction: input + + - name: input_2_4 + device: ebc100_main + slot: 2 + point: 4 + direction: input + + - name: input_2_5 + device: ebc100_main + slot: 2 + point: 5 + direction: input + + - name: input_2_6 + device: ebc100_main + slot: 2 + point: 6 + direction: input + + - name: input_2_7 + device: ebc100_main + slot: 2 + point: 7 + direction: input + + - name: input_2_8 + device: ebc100_main + slot: 2 + point: 8 + direction: input + + # ── Slot 3 — Module 3 inputs ─────────────────────────────────────────────── + - name: input_3_1 + device: ebc100_main + slot: 3 + point: 1 + direction: input + + - name: input_3_2 + device: ebc100_main + slot: 3 + point: 2 + direction: input + + - name: input_3_3 + device: ebc100_main + slot: 3 + point: 3 + direction: input + + - name: input_3_4 + device: ebc100_main + slot: 3 + point: 4 + direction: input + + - name: input_3_5 + device: ebc100_main + slot: 3 + point: 5 + direction: input + + - name: input_3_6 + device: ebc100_main + slot: 3 + point: 6 + direction: input + + - name: input_3_7 + device: ebc100_main + slot: 3 + point: 7 + direction: input + + - name: input_3_8 + device: ebc100_main + slot: 3 + point: 8 + direction: input + + # ── Outputs (uncomment when output module is added) ──────────────────────── + # - name: output_4_1 + # device: ebc100_main + # slot: 4 + # point: 1 + # direction: output + + +# ───────────────────────────────────────────────────────────────────────────── +# SEQUENCES +# name: unique identifier (used in POST /sequences/{name}/run) +# description: human-readable label +# steps: list of timed actions, executed in t_ms order +# +# Step fields: +# t_ms: milliseconds from sequence T=0 when this step fires (absolute) +# action: set_output | check_input +# signal: logical_io name +# state: (set_output only) true=ON false=OFF +# expected: (check_input only) true=ON false=OFF — failure aborts sequence +# +# ───────────────────────────────────────────────────────────────────────────── + +sequences: + # ── Example: verify all inputs on module 1 are OFF at rest ──────────────── + - name: check_all_inputs_off + description: "Verify all 24 inputs are de-energised (rest state check)" + steps: + - t_ms: 0 + action: check_input + signal: input_1_1 + expected: false + - t_ms: 0 + action: check_input + signal: input_1_2 + expected: false + - t_ms: 0 + action: check_input + signal: input_1_3 + expected: false + - t_ms: 0 + action: check_input + signal: input_1_4 + expected: false + - t_ms: 0 + action: check_input + signal: input_1_5 + expected: false + - t_ms: 0 + action: check_input + signal: input_1_6 + expected: false + - t_ms: 0 + action: check_input + signal: input_1_7 + expected: false + - t_ms: 0 + action: check_input + signal: input_1_8 + expected: false + - t_ms: 0 + action: check_input + signal: input_2_1 + expected: false + - t_ms: 0 + action: check_input + signal: input_2_2 + expected: false + - t_ms: 0 + action: check_input + signal: input_2_3 + expected: false + - t_ms: 0 + action: check_input + signal: input_2_4 + expected: false + - t_ms: 0 + action: check_input + signal: input_2_5 + expected: false + - t_ms: 0 + action: check_input + signal: input_2_6 + expected: false + - t_ms: 0 + action: check_input + signal: input_2_7 + expected: false + - t_ms: 0 + action: check_input + signal: input_2_8 + expected: false + - t_ms: 0 + action: check_input + signal: input_3_1 + expected: false + - t_ms: 0 + action: check_input + signal: input_3_2 + expected: false + - t_ms: 0 + action: check_input + signal: input_3_3 + expected: false + - t_ms: 0 + action: check_input + signal: input_3_4 + expected: false + - t_ms: 0 + action: check_input + signal: input_3_5 + expected: false + - t_ms: 0 + action: check_input + signal: input_3_6 + expected: false + - t_ms: 0 + action: check_input + signal: input_3_7 + expected: false + - t_ms: 0 + action: check_input + signal: input_3_8 + expected: false + + # ── Example: output sequencing template (requires output module in slot 4) ─ + # - name: actuate_and_verify + # description: "Set output, wait 500ms, verify input feedback" + # steps: + # - t_ms: 0 + # action: set_output + # signal: output_4_1 + # state: true + # - t_ms: 500 + # action: check_input + # signal: input_1_1 + # expected: true + # - t_ms: 1000 + # action: set_output + # signal: output_4_1 + # state: false diff --git a/config_with_outputs.yaml b/config_with_outputs.yaml new file mode 100644 index 0000000..234b548 --- /dev/null +++ b/config_with_outputs.yaml @@ -0,0 +1,149 @@ +# arnold config_with_outputs.yaml +# Device: T1H-08TDS (8-pt input, slot 1) +# T1H-08TDS (8-pt input, slot 2) +# T1K-16TD2-1 (16-pt sourcing output, slot 3) +# ───────────────────────────────────────────────────────────────────────────── + +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-point 24VDC sinking digital input + points: 8 + - slot: 2 + type: T1H-08TDS # 8-point 24VDC sinking digital input + points: 8 + - slot: 3 + type: T1K-16TD2-1 # 16-point 12-24VDC sourcing digital output + points: 16 + + +# ───────────────────────────────────────────────────────────────────────────── +# LOGICAL I/O +# ───────────────────────────────────────────────────────────────────────────── + +logical_io: + # ── Slot 1 — 8 inputs ───────────────────────────────────────────────────── + - { name: input_1_1, device: ebc100_main, slot: 1, point: 1, direction: input } + - { name: input_1_2, device: ebc100_main, slot: 1, point: 2, direction: input } + - { name: input_1_3, device: ebc100_main, slot: 1, point: 3, direction: input } + - { name: input_1_4, device: ebc100_main, slot: 1, point: 4, direction: input } + - { name: input_1_5, device: ebc100_main, slot: 1, point: 5, direction: input } + - { name: input_1_6, device: ebc100_main, slot: 1, point: 6, direction: input } + - { name: input_1_7, device: ebc100_main, slot: 1, point: 7, direction: input } + - { name: input_1_8, device: ebc100_main, slot: 1, point: 8, direction: input } + + # ── Slot 2 — 8 inputs ───────────────────────────────────────────────────── + - { name: input_2_1, device: ebc100_main, slot: 2, point: 1, direction: input } + - { name: input_2_2, device: ebc100_main, slot: 2, point: 2, direction: input } + - { name: input_2_3, device: ebc100_main, slot: 2, point: 3, direction: input } + - { name: input_2_4, device: ebc100_main, slot: 2, point: 4, direction: input } + - { name: input_2_5, device: ebc100_main, slot: 2, point: 5, direction: input } + - { name: input_2_6, device: ebc100_main, slot: 2, point: 6, direction: input } + - { name: input_2_7, device: ebc100_main, slot: 2, point: 7, direction: input } + - { name: input_2_8, device: ebc100_main, slot: 2, point: 8, direction: input } + + # ── Slot 3 — 16 outputs (T1K-16TD2-1) ──────────────────────────────────── + - { name: output_3_1, device: ebc100_main, slot: 3, point: 1, direction: output } + - { name: output_3_2, device: ebc100_main, slot: 3, point: 2, direction: output } + - { name: output_3_3, device: ebc100_main, slot: 3, point: 3, direction: output } + - { name: output_3_4, device: ebc100_main, slot: 3, point: 4, direction: output } + - { name: output_3_5, device: ebc100_main, slot: 3, point: 5, direction: output } + - { name: output_3_6, device: ebc100_main, slot: 3, point: 6, direction: output } + - { name: output_3_7, device: ebc100_main, slot: 3, point: 7, direction: output } + - { name: output_3_8, device: ebc100_main, slot: 3, point: 8, direction: output } + - { name: output_3_9, device: ebc100_main, slot: 3, point: 9, direction: output } + - { name: output_3_10, device: ebc100_main, slot: 3, point: 10, direction: output } + - { name: output_3_11, device: ebc100_main, slot: 3, point: 11, direction: output } + - { name: output_3_12, device: ebc100_main, slot: 3, point: 12, direction: output } + - { name: output_3_13, device: ebc100_main, slot: 3, point: 13, direction: output } + - { name: output_3_14, device: ebc100_main, slot: 3, point: 14, direction: output } + - { name: output_3_15, device: ebc100_main, slot: 3, point: 15, direction: output } + - { name: output_3_16, device: ebc100_main, slot: 3, point: 16, direction: output } + + +# ───────────────────────────────────────────────────────────────────────────── +# SEQUENCES +# Each output is toggled ON for 1 second, then OFF for 1 second, in order. +# Outputs 1-16 cycle sequentially: total runtime = 16 × 2s = 32s. +# ───────────────────────────────────────────────────────────────────────────── + +sequences: + - name: output_sequential_toggle + description: > + Toggle each of the 16 outputs ON for 1 second then OFF for 1 second, + cycling through outputs 1–16 in order. Total duration: 32 seconds. + steps: + # Output 1 + - { t_ms: 0, action: set_output, signal: output_3_1, state: true } + - { t_ms: 1000, action: set_output, signal: output_3_1, state: false } + # Output 2 + - { t_ms: 2000, action: set_output, signal: output_3_2, state: true } + - { t_ms: 3000, action: set_output, signal: output_3_2, state: false } + # Output 3 + - { t_ms: 4000, action: set_output, signal: output_3_3, state: true } + - { t_ms: 5000, action: set_output, signal: output_3_3, state: false } + # Output 4 + - { t_ms: 6000, action: set_output, signal: output_3_4, state: true } + - { t_ms: 7000, action: set_output, signal: output_3_4, state: false } + # Output 5 + - { t_ms: 8000, action: set_output, signal: output_3_5, state: true } + - { t_ms: 9000, action: set_output, signal: output_3_5, state: false } + # Output 6 + - { t_ms: 10000, action: set_output, signal: output_3_6, state: true } + - { t_ms: 11000, action: set_output, signal: output_3_6, state: false } + # Output 7 + - { t_ms: 12000, action: set_output, signal: output_3_7, state: true } + - { t_ms: 13000, action: set_output, signal: output_3_7, state: false } + # Output 8 + - { t_ms: 14000, action: set_output, signal: output_3_8, state: true } + - { t_ms: 15000, action: set_output, signal: output_3_8, state: false } + # Output 9 + - { t_ms: 16000, action: set_output, signal: output_3_9, state: true } + - { t_ms: 17000, action: set_output, signal: output_3_9, state: false } + # Output 10 + - { t_ms: 18000, action: set_output, signal: output_3_10, state: true } + - { t_ms: 19000, action: set_output, signal: output_3_10, state: false } + # Output 11 + - { t_ms: 20000, action: set_output, signal: output_3_11, state: true } + - { t_ms: 21000, action: set_output, signal: output_3_11, state: false } + # Output 12 + - { t_ms: 22000, action: set_output, signal: output_3_12, state: true } + - { t_ms: 23000, action: set_output, signal: output_3_12, state: false } + # Output 13 + - { t_ms: 24000, action: set_output, signal: output_3_13, state: true } + - { t_ms: 25000, action: set_output, signal: output_3_13, state: false } + # Output 14 + - { t_ms: 26000, action: set_output, signal: output_3_14, state: true } + - { t_ms: 27000, action: set_output, signal: output_3_14, state: false } + # Output 15 + - { t_ms: 28000, action: set_output, signal: output_3_15, state: true } + - { t_ms: 29000, action: set_output, signal: output_3_15, state: false } + # Output 16 + - { t_ms: 30000, action: set_output, signal: output_3_16, state: true } + - { t_ms: 31000, action: set_output, signal: output_3_16, state: false } + + # ── Safety reset — drive all outputs OFF ────────────────────────────────── + - name: all_outputs_off + description: "Force all 16 outputs OFF immediately (safety reset)" + steps: + - { t_ms: 0, action: set_output, signal: output_3_1, state: false } + - { t_ms: 0, action: set_output, signal: output_3_2, state: false } + - { t_ms: 0, action: set_output, signal: output_3_3, state: false } + - { t_ms: 0, action: set_output, signal: output_3_4, state: false } + - { t_ms: 0, action: set_output, signal: output_3_5, state: false } + - { t_ms: 0, action: set_output, signal: output_3_6, state: false } + - { t_ms: 0, action: set_output, signal: output_3_7, state: false } + - { t_ms: 0, action: set_output, signal: output_3_8, state: false } + - { t_ms: 0, action: set_output, signal: output_3_9, state: false } + - { t_ms: 0, action: set_output, signal: output_3_10, state: false } + - { t_ms: 0, action: set_output, signal: output_3_11, state: false } + - { t_ms: 0, action: set_output, signal: output_3_12, state: false } + - { t_ms: 0, action: set_output, signal: output_3_13, state: false } + - { t_ms: 0, action: set_output, signal: output_3_14, state: false } + - { t_ms: 0, action: set_output, signal: output_3_15, state: false } + - { t_ms: 0, action: set_output, signal: output_3_16, state: false } diff --git a/probe_terminator.py b/probe_terminator.py new file mode 100644 index 0000000..45181df --- /dev/null +++ b/probe_terminator.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +""" +probe_terminator.py — AutomationDirect Terminator I/O Modbus TCP prober +Target: T1H-EBC100 Ethernet controller with T1H-08TDS digital input modules +Protocol: Modbus TCP, FC02 (Read Discrete Inputs), port 502 + +Usage: + python3 probe_terminator.py [--host HOST] [--port PORT] [--unit UNIT] + [--watch] [--interval SEC] [--max-modules N] + + --host IP address of the Terminator I/O controller (default: 192.168.3.202) + --port Modbus TCP port (default: 502) + --unit Modbus unit/slave ID (default: 1; 0 = auto-discover) + --watch Continuously poll and display live state changes + --interval Poll interval in seconds when using --watch (default: 0.5) + --max-modules Maximum modules to probe during discovery (default: 3) + +Note: The T1H-EBC100 returns zeros for unmapped addresses rather than a Modbus +exception, so module count cannot be auto-detected from protocol errors alone. +Use --max-modules to match the physically installed module count. +""" + +import argparse +import sys +import time +import os + +try: + from pymodbus.client import ModbusTcpClient + from pymodbus.exceptions import ModbusException + from pymodbus.pdu import ExceptionResponse +except ImportError: + print("ERROR: pymodbus is not installed.") + print(" Install with: pip3 install pymodbus --break-system-packages") + sys.exit(1) + + +# ───────────────────────────────────────────────────────────────────────────── +# Constants +# ───────────────────────────────────────────────────────────────────────────── + +POINTS_PER_MODULE = 8 # T1H-08TDS has 8 input points +MAX_UNIT_ID_SCAN = 10 # how many unit IDs to try during auto-discovery +MODBUS_DI_START = 0 # pymodbus uses 0-based addressing; maps to 10001 +MODULE_NAMES = {8: "T1H-08TDS (8-pt DC input)"} + +# ANSI colours (disabled automatically if not a TTY) +_COLOUR = sys.stdout.isatty() +C_RESET = "\033[0m" if _COLOUR else "" +C_GREEN = "\033[32m" if _COLOUR else "" +C_RED = "\033[31m" if _COLOUR else "" +C_YELLOW = "\033[33m" if _COLOUR else "" +C_CYAN = "\033[36m" if _COLOUR else "" +C_BOLD = "\033[1m" if _COLOUR else "" +C_DIM = "\033[2m" if _COLOUR else "" + + +# ───────────────────────────────────────────────────────────────────────────── +# Helper: single FC02 read, returns list[bool] or None on error +# ───────────────────────────────────────────────────────────────────────────── + +def read_discrete_inputs(client, address, count, unit): + """Read `count` discrete inputs starting at 0-based `address`. Returns list[bool] or None.""" + try: + # pymodbus >= 3.x uses device_id=; older versions use slave= + try: + rr = client.read_discrete_inputs(address=address, count=count, device_id=unit) + except TypeError: + rr = client.read_discrete_inputs(address=address, count=count, slave=unit) + if rr.isError() or isinstance(rr, ExceptionResponse): + return None + return rr.bits[:count] + except ModbusException: + return None + except Exception: + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# Step 1: Discover unit ID +# ───────────────────────────────────────────────────────────────────────────── + +def discover_unit_id(client): + """ + Try unit IDs 1..MAX_UNIT_ID_SCAN and return the first one that responds to + a FC02 read. The T1H-EBC100 usually responds to any unit ID over Modbus TCP + but we still honour the configured ID. + Returns the first responding unit ID, or 1 as a fallback. + """ + print(f"{C_CYAN}Scanning unit IDs 1–{MAX_UNIT_ID_SCAN}...{C_RESET}") + for uid in range(1, MAX_UNIT_ID_SCAN + 1): + result = read_discrete_inputs(client, MODBUS_DI_START, 1, uid) + if result is not None: + print(f" Unit ID {C_BOLD}{uid}{C_RESET} responded.") + return uid + else: + print(f" Unit ID {uid} — no response") + print(f"{C_YELLOW}No unit ID responded; defaulting to 1{C_RESET}") + return 1 + + +# ───────────────────────────────────────────────────────────────────────────── +# Step 2: Discover how many 8-point modules are present +# ───────────────────────────────────────────────────────────────────────────── + +def discover_modules(client, unit, max_modules): + """ + Read blocks of POINTS_PER_MODULE discrete inputs for each slot up to max_modules. + The T1H-EBC100 returns zeros for unmapped slots rather than Modbus exceptions, + so we rely on max_modules to define the installed hardware count. + Returns the number of responsive slots (capped at max_modules). + """ + print(f"\n{C_CYAN}Probing {max_modules} slot(s) × {POINTS_PER_MODULE}-pt...{C_RESET}") + print(f" {C_DIM}(T1H-EBC100 returns 0 for empty slots — set --max-modules to match physical count){C_RESET}") + num_modules = 0 + for slot in range(max_modules): + addr = slot * POINTS_PER_MODULE + result = read_discrete_inputs(client, addr, POINTS_PER_MODULE, unit) + if result is None: + print(f" Slot {slot + 1}: no response — check connection") + break + print(f" Slot {slot + 1}: OK (Modbus DI {addr}–{addr + POINTS_PER_MODULE - 1})") + num_modules += 1 + return num_modules + + +# ───────────────────────────────────────────────────────────────────────────── +# Step 3: Read all discovered inputs and format output +# ───────────────────────────────────────────────────────────────────────────── + +def read_all_inputs(client, unit, num_modules): + """Read all inputs for discovered modules. Returns list[bool] or None on error.""" + total = num_modules * POINTS_PER_MODULE + return read_discrete_inputs(client, MODBUS_DI_START, total, unit) + + +def format_inputs(bits, num_modules, prev_bits=None): + """ + Format input states as a module-by-module table. + Highlights changed bits if prev_bits is provided. + Returns a list of strings (lines). + """ + lines = [] + lines.append(f"{C_BOLD}{'Module':<10} {'Points':^72}{C_RESET}") + lines.append(f"{'':10} " + " ".join(f"{'pt'+str(i+1):^5}" for i in range(POINTS_PER_MODULE))) + lines.append("─" * 80) + + for m in range(num_modules): + pts = [] + for p in range(POINTS_PER_MODULE): + idx = m * POINTS_PER_MODULE + p + val = bits[idx] + changed = (prev_bits is not None) and (val != prev_bits[idx]) + if val: + label = f"{C_GREEN}{'ON':^5}{C_RESET}" + else: + label = f"{C_DIM}{'off':^5}{C_RESET}" + if changed: + label = f"{C_YELLOW}{'*':1}{C_RESET}" + label + else: + label = " " + label + pts.append(label) + addr_start = m * POINTS_PER_MODULE + 1 # 1-based Modbus reference + addr_end = addr_start + POINTS_PER_MODULE - 1 + mod_label = f"Mod {m+1:>2} ({addr_start:05d}–{addr_end:05d})" + lines.append(f"{mod_label:<30} " + " ".join(pts)) + + active = sum(1 for b in bits if b) + lines.append("─" * 80) + lines.append(f" {active} of {len(bits)} inputs active") + return lines + + +# ───────────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Probe AutomationDirect Terminator I/O via Modbus TCP" + ) + parser.add_argument("--host", default="192.168.3.202", + help="Controller IP address (default: 192.168.3.202)") + parser.add_argument("--port", type=int, default=502, + help="Modbus TCP port (default: 502)") + parser.add_argument("--unit", type=int, default=0, + help="Modbus unit/slave ID (default: 0 = auto-discover)") + parser.add_argument("--watch", action="store_true", + help="Continuously poll and display live state changes") + parser.add_argument("--interval", type=float, default=0.5, + help="Poll interval in seconds for --watch (default: 0.5)") + parser.add_argument("--max-modules", type=int, default=3, + help="Number of modules installed (default: 3 for 3x T1H-08TDS)") + args = parser.parse_args() + + print(f"\n{C_BOLD}=== Terminator I/O Modbus TCP Prober ==={C_RESET}") + print(f" Host : {args.host}:{args.port}") + print(f" Unit : {'auto-discover' if args.unit == 0 else args.unit}") + print() + + # ── Connect ────────────────────────────────────────────────────────────── + client = ModbusTcpClient(host=args.host, port=args.port, timeout=2) + if not client.connect(): + print(f"{C_RED}ERROR: Could not connect to {args.host}:{args.port}{C_RESET}") + sys.exit(1) + print(f"{C_GREEN}Connected to {args.host}:{args.port}{C_RESET}") + + # ── Discover unit ID ───────────────────────────────────────────────────── + unit = args.unit if args.unit != 0 else discover_unit_id(client) + + # ── Discover modules ────────────────────────────────────────────────────── + num_modules = discover_modules(client, unit, args.max_modules) + if num_modules == 0: + print(f"{C_RED}ERROR: No modules found. Check wiring and power.{C_RESET}") + client.close() + sys.exit(1) + + total_pts = num_modules * POINTS_PER_MODULE + print(f"\n{C_GREEN}Found {num_modules} module(s), {total_pts} input points total.{C_RESET}") + print(f" Module type : {MODULE_NAMES.get(POINTS_PER_MODULE, f'{POINTS_PER_MODULE}-pt module')}") + print(f" Modbus address : 10001 – {10000 + total_pts} (FC02)") + print(f" Unit ID : {unit}") + + # ── Single-shot read ────────────────────────────────────────────────────── + print() + bits = read_all_inputs(client, unit, num_modules) + if bits is None: + print(f"{C_RED}ERROR: Failed to read inputs.{C_RESET}") + client.close() + sys.exit(1) + + for line in format_inputs(bits, num_modules): + print(line) + + if not args.watch: + client.close() + print(f"\n{C_DIM}Tip: run with --watch to monitor live state changes{C_RESET}") + return + + # ── Watch mode ──────────────────────────────────────────────────────────── + print(f"\n{C_CYAN}Watch mode: polling every {args.interval}s — press Ctrl+C to stop{C_RESET}\n") + prev_bits = bits + poll_count = 0 + try: + while True: + time.sleep(args.interval) + new_bits = read_all_inputs(client, unit, num_modules) + if new_bits is None: + print(f"{C_RED}[{time.strftime('%H:%M:%S')}] Read error — retrying...{C_RESET}") + # attempt reconnect + client.close() + time.sleep(1) + client.connect() + continue + + poll_count += 1 + changed = any(a != b for a, b in zip(new_bits, prev_bits)) + + if changed or poll_count == 1: + # Clear screen and redraw + if _COLOUR: + print("\033[H\033[J", end="") # clear terminal + print(f"{C_BOLD}=== Terminator I/O Live Monitor ==={C_RESET} " + f"{C_DIM}[{time.strftime('%H:%M:%S')}] poll #{poll_count}{C_RESET}") + print(f" {args.host}:{args.port} unit={unit} " + f"interval={args.interval}s Ctrl+C to stop\n") + for line in format_inputs(new_bits, num_modules, prev_bits if changed else None): + print(line) + if changed: + print(f"\n {C_YELLOW}* = changed since last poll{C_RESET}") + + prev_bits = new_bits + + except KeyboardInterrupt: + print(f"\n{C_DIM}Stopped.{C_RESET}") + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/runs.log b/runs.log new file mode 100644 index 0000000..b7246e0 --- /dev/null +++ b/runs.log @@ -0,0 +1,12 @@ +{"run_id": "78184f85-4af7-440d-8775-e9295fbff3c5", "sequence_name": "check_all_inputs_off", "status": "success", "started_at": "2026-02-28T15:51:57.320554+00:00", "finished_at": "2026-02-28T15:51:57.321223+00:00", "duration_ms": 2, "steps_completed": 24, "total_steps": 24, "failed_step": null, "error_message": ""} +{"run_id": "aad3d36c-f181-4883-9b44-840db2a8517e", "sequence_name": "check_all_inputs_off", "status": "success", "started_at": "2026-02-28T15:51:58.472703+00:00", "finished_at": "2026-02-28T15:51:58.472898+00:00", "duration_ms": 0, "steps_completed": 24, "total_steps": 24, "failed_step": null, "error_message": ""} +{"run_id": "ba535ef2-005b-443c-b693-4fc1f4b73b0a", "sequence_name": "output_sequential_toggle", "status": "success", "started_at": "2026-02-28T16:06:26.061659+00:00", "finished_at": "2026-02-28T16:06:57.062756+00:00", "duration_ms": 31001, "steps_completed": 32, "total_steps": 32, "failed_step": null, "error_message": ""} +{"run_id": "f2d16dcf-5d74-4ee5-a12a-48c6e546a095", "sequence_name": "check_all_inputs_off", "status": "success", "started_at": "2026-03-01T12:29:00.414932+00:00", "finished_at": "2026-03-01T12:29:00.415182+00:00", "duration_ms": 0, "steps_completed": 24, "total_steps": 24, "failed_step": null, "error_message": ""} +{"run_id": "cb9aefd5-b1aa-4991-9cf7-2ab465a0cf45", "sequence_name": "output_sequential_toggle", "status": "success", "started_at": "2026-03-01T13:28:09.839584+00:00", "finished_at": "2026-03-01T13:28:40.840764+00:00", "duration_ms": 31001, "steps_completed": 32, "total_steps": 32, "failed_step": null, "error_message": ""} +{"run_id": "260334dc-a9bb-4c15-8856-28313e658a91", "sequence_name": "output_sequential_toggle", "status": "success", "started_at": "2026-03-01T13:30:18.997227+00:00", "finished_at": "2026-03-01T13:30:49.998296+00:00", "duration_ms": 31001, "steps_completed": 32, "total_steps": 32, "failed_step": null, "error_message": ""} +{"run_id": "f3b304a2-8c1b-4724-805a-56c98a25b7e1", "sequence_name": "output_sequential_toggle", "status": "success", "started_at": "2026-03-01T13:43:28.348776+00:00", "finished_at": "2026-03-01T13:43:59.349884+00:00", "duration_ms": 31001, "steps_completed": 32, "total_steps": 32, "current_step_index": -1, "failed_step": null, "error_message": ""} +{"run_id": "770b6d2b-d6df-42f7-a2e3-85f4a9c1b112", "sequence_name": "output_sequential_toggle", "status": "success", "started_at": "2026-03-01T13:48:15.398281+00:00", "finished_at": "2026-03-01T13:48:46.400166+00:00", "duration_ms": 31001, "steps_completed": 32, "total_steps": 32, "current_step_index": -1, "failed_step": null, "error_message": ""} +{"run_id": "a719637b-9aba-49bd-bdfb-9894fe6908a5", "sequence_name": "output_sequential_toggle", "status": "success", "started_at": "2026-03-01T13:53:44.159832+00:00", "finished_at": "2026-03-01T13:54:15.160892+00:00", "duration_ms": 31001, "steps_completed": 32, "total_steps": 32, "current_step_index": -1, "failed_step": null, "error_message": ""} +{"run_id": "dd5ca4f1-3013-4737-9837-94303ffda65a", "sequence_name": "output_sequential_toggle", "status": "success", "started_at": "2026-03-01T14:56:55.946648+00:00", "finished_at": "2026-03-01T14:57:26.947726+00:00", "duration_ms": 31001, "steps_completed": 32, "total_steps": 32, "current_step_index": -1, "failed_step": null, "error_message": ""} +{"run_id": "8a62c3db-2a60-4816-b5e3-a571e298febe", "sequence_name": "output_sequential_toggle", "status": "success", "started_at": "2026-03-02T22:34:08.018599+00:00", "finished_at": "2026-03-02T22:34:39.019690+00:00", "duration_ms": 31001, "steps_completed": 32, "total_steps": 32, "current_step_index": -1, "failed_step": null, "error_message": ""} +{"run_id": "ccc22f7d-bdb7-4a29-8853-9e368f25b3ab", "sequence_name": "all_outputs_off", "status": "success", "started_at": "2026-03-02T22:39:09.278290+00:00", "finished_at": "2026-03-02T22:39:09.295550+00:00", "duration_ms": 17, "steps_completed": 16, "total_steps": 16, "current_step_index": -1, "failed_step": null, "error_message": ""} diff --git a/server.py b/server.py new file mode 100644 index 0000000..5c47ed1 --- /dev/null +++ b/server.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +server.py — Arnold I/O server entrypoint. + +Usage: + python3 server.py [--config FILE] [--host HOST] [--port PORT] [--log-level LEVEL] + + --config YAML config file (default: config.yaml) + --host API listen address (default: 0.0.0.0) + --port API listen port (default: 8000) + --log-level debug | info | warning | error (default: info) + +Interactive API docs at http://:/docs once running. +""" + +from __future__ import annotations + +import argparse +import logging +import signal +import sys +import threading +import time +from pathlib import Path + +import uvicorn + +from arnold.config import load as load_config, ConfigError +from arnold.terminator_io import IORegistry +from arnold.sequencer import Sequencer +from arnold.api import AppContext, create_app + + +def _setup_logging(level: str) -> None: + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s %(levelname)-8s %(name)s %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Arnold — Terminator I/O server") + parser.add_argument("--config", default="config.yaml") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--log-level", default="info", + choices=["debug", "info", "warning", "error"]) + args = parser.parse_args() + + _setup_logging(args.log_level) + log = logging.getLogger("arnold.server") + + # 1. Load config ----------------------------------------------------------- + log.info("Loading config: %s", args.config) + try: + config = load_config(args.config) + except ConfigError as exc: + log.error("Config error: %s", exc) + sys.exit(1) + + log.info("Config: %d device(s) %d signal(s) %d sequence(s)", + len(config.devices), len(config.logical_io), len(config.sequences)) + for dev in config.devices: + log.info(" %-20s %s:%d %d din %d dout %d ain %d aout poll=%dms", + dev.id, dev.host, dev.port, + dev.total_input_points(), dev.total_output_points(), + dev.total_analog_input_channels(), dev.total_analog_output_channels(), + dev.poll_interval_ms) + + # 2. Build runtime objects ------------------------------------------------- + registry = IORegistry(config) + sequencer = Sequencer(config, registry, Path("runs.log")) + ctx = AppContext( + config=config, + registry=registry, + sequencer=sequencer, + started_at=time.monotonic(), + ) + app = create_app(ctx) + + # 3. Start poll threads ---------------------------------------------------- + log.info("Starting poll threads...") + registry.start() + + # 4. Apply output defaults (digital + analog) -------------------------------- + digital_defaults = [ + s for s in config.logical_io + if s.direction == "output" and s.value_type == "bool" + and s.default_state is not None + ] + analog_defaults = [ + s for s in config.logical_io + if s.direction == "output" and s.value_type == "int" + and s.default_value is not None + ] + total_defaults = len(digital_defaults) + len(analog_defaults) + if total_defaults: + log.info("Applying %d output default(s)...", total_defaults) + time.sleep(0.5) # give Modbus connection a moment to establish + for sig in digital_defaults: + driver = registry.driver(sig.device) + if driver and sig.default_state is not None: + ok = driver.write_output(sig.modbus_address, sig.default_state) + log.info(" %s → %s (%s)", sig.name, + "ON" if sig.default_state else "OFF", + "ok" if ok else "FAILED") + for sig in analog_defaults: + driver = registry.driver(sig.device) + if driver and sig.default_value is not None: + ok = driver.write_register(sig.modbus_address, sig.default_value) + log.info(" %s → %d (%s)", sig.name, + sig.default_value, "ok" if ok else "FAILED") + + # 5. Graceful shutdown ----------------------------------------------------- + shutdown = threading.Event() + + def _on_signal(sig, _frame): + log.info("Signal %s received — shutting down...", sig) + shutdown.set() + + signal.signal(signal.SIGINT, _on_signal) + signal.signal(signal.SIGTERM, _on_signal) + + # 6. Start uvicorn in a daemon thread -------------------------------------- + uv_server = uvicorn.Server(uvicorn.Config( + app, + host=args.host, + port=args.port, + log_level=args.log_level, + access_log=False, + )) + uv_thread = threading.Thread(target=uv_server.run, daemon=True) + uv_thread.start() + log.info("API listening on http://%s:%d (docs: /docs)", args.host, args.port) + + # Block until SIGINT / SIGTERM + try: + while not shutdown.is_set(): + shutdown.wait(1.0) + except KeyboardInterrupt: + pass + + # 7. Clean shutdown -------------------------------------------------------- + log.info("Stopping poll threads...") + registry.stop() + + log.info("Stopping API server...") + uv_server.should_exit = True + uv_thread.join(timeout=5) + + log.info("Done.") + + +if __name__ == "__main__": + main() diff --git a/tui.py b/tui.py new file mode 100644 index 0000000..b05184d --- /dev/null +++ b/tui.py @@ -0,0 +1,883 @@ +#!/usr/bin/env python3 +""" +tui.py — Interactive TUI debugger for the Terminator I/O system. + +Usage: + python3 tui.py [config.yaml] + +Default config path: config.yaml (same directory as this script). + +Layout: + ┌─ Status bar (device health, poll rate) ────────────────────────────────┐ + │ Inputs (live) │ Outputs (selectable) │ Sequences (runnable) │ + └─ Footer (keybindings) ─────────────────────────────────────────────────┘ + +Keybindings: + Tab Cycle focus between Outputs and Sequences panels + ↑ / ↓ Navigate the focused panel + Space/Enter Outputs: toggle selected | Sequences: run selected + 0 All outputs OFF (from Outputs panel) + 1 All outputs ON (from Outputs panel) + r Force reconnect all devices + q Quit +""" + +from __future__ import annotations + +import sys +import time +import logging +import argparse +import threading +from pathlib import Path +from typing import Any, Callable + +# ── Textual ────────────────────────────────────────────────────────────────── +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal +from textual.reactive import reactive +from textual.timer import Timer +from textual.widgets import Footer, Static +from textual import on +from textual.message import Message + +# ── Arnold internals ───────────────────────────────────────────────────────── +sys.path.insert(0, str(Path(__file__).parent)) +from arnold.config import load as load_config, Config, ConfigError, Sequence +from arnold.terminator_io import IORegistry, SignalState +from arnold.sequencer import Sequencer, RunResult + +# Suppress pymodbus noise in TUI mode +logging.getLogger("pymodbus").setLevel(logging.CRITICAL) +logging.getLogger("arnold").setLevel(logging.WARNING) + + +# ───────────────────────────────────────────────────────────────────────────── +# Input panel +# ───────────────────────────────────────────────────────────────────────────── + +class InputPanel(Static): + """Live table of all input signals, grouped by type (digital / analog).""" + + DEFAULT_CSS = """ + InputPanel { + border: solid $primary; + padding: 0 1; + height: 100%; + } + """ + + def __init__( + self, + input_signals: list[tuple[str, str]], # [(name, value_type), ...] + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._input_signals = input_signals + self._digital = [n for n, vt in input_signals if vt == "bool"] + self._analog = [n for n, vt in input_signals if vt == "int"] + self._snapshot: dict[str, SignalState] = {} + + def update_snapshot(self, snapshot: dict[str, SignalState]) -> None: + self._snapshot = snapshot + self.refresh() + + def render(self) -> str: + lines: list[str] = ["[bold]Inputs[/bold]"] + + if not self._digital and not self._analog: + lines.append(" [dim](none)[/dim]") + return "\n".join(lines) + + # Digital inputs + if self._digital: + lines.append("") + if self._analog: + lines.append(" [dim underline]Digital[/dim underline]") + for name in self._digital: + state = self._snapshot.get(name) + if state is None or state.stale: + indicator = "[dim]?[/dim]" + color = "dim" + elif state.value: + indicator = "[bold green]●[/bold green]" + color = "green" + else: + indicator = "[dim]○[/dim]" + color = "white" + lines.append(f" {indicator} [{color}]{name}[/{color}]") + + # Analog inputs + if self._analog: + lines.append("") + if self._digital: + lines.append(" [dim underline]Analog[/dim underline]") + for name in self._analog: + state = self._snapshot.get(name) + if state is None or state.stale: + val_str = "[dim]?[/dim]" + color = "dim" + else: + val_str = f"[bold cyan]{state.value:>5}[/bold cyan]" + color = "cyan" + lines.append(f" [{color}]{name:<20}[/{color}] {val_str}") + + return "\n".join(lines) + + +# ───────────────────────────────────────────────────────────────────────────── +# Output panel +# ───────────────────────────────────────────────────────────────────────────── + +class OutputPanel(Static, can_focus=True): + """ + Selectable list of output signals. + Displays shadow state (what we last wrote) — the EBC100 has no readback. + + Digital outputs: Space/Enter to toggle ON/OFF, 0/1 for all off/on. + Analog outputs: +/- to adjust value by step (100 default), Enter to write. + """ + + DEFAULT_CSS = """ + OutputPanel { + border: solid $accent; + padding: 0 1; + height: 100%; + } + OutputPanel:focus { + border: solid $accent-lighten-2; + } + """ + + BINDINGS = [ + Binding("up", "cursor_up", "Up", show=False), + Binding("down", "cursor_down", "Down", show=False), + Binding("space", "do_toggle", "Toggle", show=False), + Binding("enter", "do_write", "Write", show=False), + Binding("0", "all_off", "All OFF", show=False), + Binding("1", "all_on", "All ON", show=False), + Binding("plus_sign", "analog_up", "+", show=False), + Binding("hyphen_minus","analog_down", "-", show=False), + ] + + cursor: reactive[int] = reactive(0) + + # Step size for analog +/- adjustment + ANALOG_STEP = 100 + + # ── Messages ───────────────────────────────────────────────────────────── + + class ToggleOutput(Message): + """Digital output toggle request.""" + def __init__(self, signal: str, current_value: bool) -> None: + super().__init__() + self.signal = signal + self.current_value = current_value + + class WriteAnalog(Message): + """Analog output write request (user pressed Enter on an analog output).""" + def __init__(self, signal: str, value: int) -> None: + super().__init__() + self.signal = signal + self.value = value + + class AllOutputs(Message): + """Set all digital outputs to a given state.""" + def __init__(self, value: bool) -> None: + super().__init__() + self.value = value + + # ── Init / update ───────────────────────────────────────────────────────── + + def __init__( + self, + output_signals: list[tuple[str, str]], # [(name, value_type), ...] + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._signals: list[tuple[str, str]] = output_signals + self._names: list[str] = [n for n, _ in output_signals] + self._type_map: dict[str, str] = {n: vt for n, vt in output_signals} + self._state: dict[str, bool | int] = {} + for name, vt in output_signals: + self._state[name] = 0 if vt == "int" else False + + # Pending analog value edits (before Enter commits) + self._analog_pending: dict[str, int] = {} + + def update_output_state(self, state: dict[str, bool | int]) -> None: + self._state = state + self.refresh() + + # ── Render ──────────────────────────────────────────────────────────────── + + def render(self) -> str: + digital = [(n, vt) for n, vt in self._signals if vt == "bool"] + analog = [(n, vt) for n, vt in self._signals if vt == "int"] + + lines: list[str] = ["[bold]Outputs[/bold]"] + if not self._names: + lines.append(" [dim](no outputs configured)[/dim]") + return "\n".join(lines) + + # Digital outputs + if digital: + lines.append("") + if analog: + lines.append(" [dim underline]Digital[/dim underline] [dim](Space toggle · 0/1 all)[/dim]") + else: + lines.append(" [dim](↑↓ navigate · Space toggle · 0/1 all)[/dim]") + for name, _ in digital: + i = self._names.index(name) + val = self._state.get(name, False) + indicator = "[bold green]●[/bold green]" if val else "[dim]○[/dim]" + val_str = "[bold green]ON [/bold green]" if val else "OFF" + if i == self.cursor: + line = f"[reverse] ► [/reverse] {indicator} [reverse]{name}[/reverse] {val_str}" + else: + line = f" {indicator} {name} {val_str}" + lines.append(line) + + # Analog outputs + if analog: + lines.append("") + if digital: + lines.append(" [dim underline]Analog[/dim underline] [dim](+/- adjust · Enter write)[/dim]") + else: + lines.append(" [dim](↑↓ navigate · +/- adjust · Enter write)[/dim]") + for name, _ in analog: + i = self._names.index(name) + committed = self._state.get(name, 0) + pending = self._analog_pending.get(name) + if pending is not None and pending != committed: + val_str = f"[bold yellow]{pending:>5}[/bold yellow] [dim](pending)[/dim]" + else: + val_str = f"[bold cyan]{committed:>5}[/bold cyan]" + if i == self.cursor: + line = f"[reverse] ► [/reverse] [reverse]{name}[/reverse] {val_str}" + else: + line = f" {name} {val_str}" + lines.append(line) + + return "\n".join(lines) + + # ── Actions ─────────────────────────────────────────────────────────────── + + def _current_type(self) -> str | None: + if not self._names: + return None + return self._type_map.get(self._names[self.cursor]) + + def action_cursor_up(self) -> None: + if self._names: + self.cursor = (self.cursor - 1) % len(self._names) + + def action_cursor_down(self) -> None: + if self._names: + self.cursor = (self.cursor + 1) % len(self._names) + + def action_do_toggle(self) -> None: + """Space: toggle digital output, or increment analog by step.""" + if not self._names: + return + name = self._names[self.cursor] + if self._type_map.get(name) == "int": + self._adjust_analog(name, self.ANALOG_STEP) + else: + current = bool(self._state.get(name, False)) + self.post_message(self.ToggleOutput(signal=name, current_value=current)) + + def action_do_write(self) -> None: + """Enter: toggle digital output, or commit pending analog value.""" + if not self._names: + return + name = self._names[self.cursor] + if self._type_map.get(name) == "int": + pending = self._analog_pending.get(name) + if pending is not None: + self.post_message(self.WriteAnalog(signal=name, value=pending)) + # Clear pending — will be updated from state after write + self._analog_pending.pop(name, None) + # If no pending change, Enter is a no-op for analog + else: + current = bool(self._state.get(name, False)) + self.post_message(self.ToggleOutput(signal=name, current_value=current)) + + def action_analog_up(self) -> None: + if not self._names: + return + name = self._names[self.cursor] + if self._type_map.get(name) == "int": + self._adjust_analog(name, self.ANALOG_STEP) + + def action_analog_down(self) -> None: + if not self._names: + return + name = self._names[self.cursor] + if self._type_map.get(name) == "int": + self._adjust_analog(name, -self.ANALOG_STEP) + + def _adjust_analog(self, name: str, delta: int) -> None: + current = self._analog_pending.get(name) + if current is None: + current = int(self._state.get(name, 0)) + new_val = max(0, min(65535, current + delta)) + self._analog_pending[name] = new_val + self.refresh() + + def action_all_off(self) -> None: + self.post_message(self.AllOutputs(value=False)) + + def action_all_on(self) -> None: + self.post_message(self.AllOutputs(value=True)) + + +# ───────────────────────────────────────────────────────────────────────────── +# Sequence panel +# ───────────────────────────────────────────────────────────────────────────── + +class SequencePanel(Static, can_focus=True): + """ + Idle mode: navigable list of all sequences; Enter/Space to run. + Running mode: full step list for the active sequence with the current + step highlighted and a progress bar header. + """ + + DEFAULT_CSS = """ + SequencePanel { + border: solid $warning-darken-2; + padding: 0 1; + height: 100%; + } + SequencePanel:focus { + border: solid $warning; + } + """ + + BINDINGS = [ + Binding("up", "cursor_up", "Up", show=False), + Binding("down", "cursor_down","Down", show=False), + Binding("space", "do_run", "Run", show=False), + Binding("enter", "do_run", "Run", show=False), + ] + + cursor: reactive[int] = reactive(0) + + # ── Messages ───────────────────────────────────────────────────────────── + + class RunSequence(Message): + def __init__(self, name: str) -> None: + super().__init__() + self.name = name + + # ── Init / update ───────────────────────────────────────────────────────── + + def __init__(self, sequences: list[Sequence], **kwargs: Any) -> None: + super().__init__(**kwargs) + self._sequences: list[Sequence] = sequences + self._seq_by_name: dict[str, Sequence] = {s.name: s for s in sequences} + + self._active_run: RunResult | None = None + self._last_result: RunResult | None = None + + def update_run_state( + self, + active_run: RunResult | None, + last_result: RunResult | None, + ) -> None: + self._active_run = active_run + self._last_result = last_result + self.refresh() + + # ── Render: dispatch ────────────────────────────────────────────────────── + + def render(self) -> str: + if self._active_run and self._active_run.status in ("pending", "running"): + return self._render_running() + return self._render_idle() + + # ── Render: idle (sequence list) ────────────────────────────────────────── + + def _render_idle(self) -> str: + lines: list[str] = [ + "[bold]Sequences[/bold] [dim](↑↓ navigate · Enter run)[/dim]\n" + ] + if not self._sequences: + lines.append(" [dim](no sequences configured)[/dim]") + return "\n".join(lines) + + for i, seq in enumerate(self._sequences): + total = len(seq.steps) + if i == self.cursor: + line = ( + f"[reverse] ► [/reverse] [reverse]{seq.name}[/reverse]" + f" [dim]{total} steps[/dim]" + ) + else: + line = f" {seq.name} [dim]{total} steps[/dim]" + lines.append(line) + if seq.description: + short = seq.description.strip().split("\n")[0][:60] + lines.append(f" [dim]{short}[/dim]") + + # Last result summary (shown below the list when idle) + if self._last_result: + r = self._last_result + color = {"success": "green", "failed": "red", "error": "red"}.get( + r.status, "dim" + ) + lines.append( + f"\n[dim]Last run:[/dim] [{color}]{r.sequence_name} " + f"→ {r.status.upper()}[/{color}]" + f" [dim]{r.steps_completed}/{r.total_steps} steps" + f" {r.duration_ms} ms[/dim]" + ) + if r.failed_step: + fs = r.failed_step + lines.append( + f" [red]✗ step {fs.step_index} ({fs.t_ms} ms):" + f" {fs.detail}[/red]" + ) + + return "\n".join(lines) + + # ── Render: running (step list) ─────────────────────────────────────────── + + def _render_running(self) -> str: + run = self._active_run + assert run is not None + seq = self._seq_by_name.get(run.sequence_name) + if seq is None: + return f"[yellow]Running: {run.sequence_name}[/yellow]\n[dim](steps unknown)[/dim]" + + total = len(seq.steps) + done = run.steps_completed + current = run.current_step_index + pct = int(done / total * 100) if total else 0 + bar = _progress_bar(pct, width=16) + + lines: list[str] = [ + f"[bold yellow]▶ {seq.name}[/bold yellow]" + f" {bar} [yellow]{done}/{total}[/yellow]\n" + ] + + for i, step in enumerate(seq.steps): + t_s = step.t_ms / 1000 + t_str = f"{t_s:6.1f}s" + + if step.action == "set_output": + if step.value is not None: + # Analog output + action = f"set {step.signal} → {step.value}" + else: + val = "ON " if step.state else "OFF" + action = f"set {step.signal} → {val}" + elif step.action == "check_input": + if step.expected_value is not None: + tol = f"±{step.tolerance}" if step.tolerance else "" + action = f"chk {step.signal} == {step.expected_value}{tol}" + else: + exp = "ON " if step.expected else "OFF" + action = f"chk {step.signal} == {exp}" + elif step.action == "wait_input": + t_out = f"{step.timeout_ms} ms" if step.timeout_ms else "?" + if step.expected_value is not None: + tol = f"±{step.tolerance}" if step.tolerance else "" + action = f"wait {step.signal} == {step.expected_value}{tol} (timeout {t_out})" + else: + exp = "ON " if step.expected else "OFF" + action = f"wait {step.signal} == {exp} (timeout {t_out})" + else: + action = f"{step.action} {step.signal}" + + if i == current: + # Currently executing — bright highlight + line = f"[reverse][bold yellow] ► {t_str} {action} [/bold yellow][/reverse]" + elif i < done: + # Already completed + line = f" [dim green]✓ {t_str} {action}[/dim green]" + else: + # Pending + line = f" [dim] {t_str} {action}[/dim]" + + lines.append(line) + + return "\n".join(lines) + + # ── Actions ─────────────────────────────────────────────────────────────── + + def action_cursor_up(self) -> None: + if self._sequences: + self.cursor = (self.cursor - 1) % len(self._sequences) + + def action_cursor_down(self) -> None: + if self._sequences: + self.cursor = (self.cursor + 1) % len(self._sequences) + + def action_do_run(self) -> None: + if not self._sequences: + return + self.post_message(self.RunSequence(name=self._sequences[self.cursor].name)) + + +def _progress_bar(pct: int, width: int = 16) -> str: + filled = int(width * pct / 100) + return "[" + "█" * filled + "░" * (width - filled) + "]" + + +# ───────────────────────────────────────────────────────────────────────────── +# Status bar +# ───────────────────────────────────────────────────────────────────────────── + +class StatusBar(Static): + DEFAULT_CSS = """ + StatusBar { + dock: top; + height: 1; + padding: 0 1; + } + """ + + def __init__(self, **kwargs: Any) -> None: + super().__init__("", **kwargs) + self._stats: list[dict] = [] + self._msg: str = "" + + def update_stats(self, stats: list[dict]) -> None: + self._stats = stats + self._rebuild() + + def set_message(self, msg: str) -> None: + self._msg = msg + self._rebuild() + + def _rebuild(self) -> None: + parts: list[str] = [] + for s in self._stats: + dot = "[green]●[/green]" if s.get("connected") else "[red]✗[/red]" + parts.append( + f"{dot} {s['device_id']} " + f"{s.get('achieved_hz', 0):.0f} Hz " + f"err={s.get('error_count', 0)}" + ) + status = " ".join(parts) if parts else "[dim]no devices[/dim]" + msg = f" [yellow]{self._msg}[/yellow]" if self._msg else "" + self.update(status + msg) + + +# ───────────────────────────────────────────────────────────────────────────── +# Main application +# ───────────────────────────────────────────────────────────────────────────── + +class TerminatorTUI(App): + """Arnold Terminator I/O debug TUI.""" + + TITLE = "Arnold — Terminator I/O Debugger" + + CSS = """ + Screen { + layout: vertical; + } + #main-area { + height: 1fr; + layout: horizontal; + } + #input-panel { + width: 1fr; + height: 100%; + overflow-y: auto; + } + #output-panel { + width: 1fr; + height: 100%; + overflow-y: auto; + } + #sequence-panel { + width: 2fr; + height: 100%; + overflow-y: auto; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("r", "reconnect", "Reconnect"), + Binding("tab", "cycle_focus","Tab", show=False), + ] + + def __init__(self, config: Config, registry: IORegistry, + sequencer: Sequencer) -> None: + super().__init__() + self._cfg = config + self._io = registry + self._seq = sequencer + + # Build typed signal lists: [(name, value_type), ...] + self._input_signals: list[tuple[str, str]] = [ + (s.name, s.value_type) + for s in config.logical_io if s.direction == "input" + ] + self._output_signals: list[tuple[str, str]] = [ + (s.name, s.value_type) + for s in config.logical_io if s.direction == "output" + ] + self._input_names = [n for n, _ in self._input_signals] + self._output_names = [n for n, _ in self._output_signals] + self._output_type_map: dict[str, str] = {n: vt for n, vt in self._output_signals} + + # Shadow output state — updated on write (EBC100 has no output readback) + self._output_state: dict[str, bool | int] = {} + for name, vt in self._output_signals: + self._output_state[name] = 0 if vt == "int" else False + + # Last completed run (for the sequence panel summary) + self._last_run_id: str | None = None + self._last_result: RunResult | None = None + + self._refresh_timer: Timer | None = None + self._status_clear_timer: Timer | None = None + + # ── Layout ─────────────────────────────────────────────────────────────── + + def compose(self) -> ComposeResult: + yield StatusBar(id="status-bar") + with Horizontal(id="main-area"): + yield InputPanel(input_signals=self._input_signals, id="input-panel") + yield OutputPanel(output_signals=self._output_signals, id="output-panel") + yield SequencePanel( + sequences=self._cfg.sequences, + id="sequence-panel", + ) + yield Footer() + + def on_mount(self) -> None: + self.query_one(OutputPanel).focus() + self._io.start() + self._refresh_timer = self.set_interval(1 / 20, self._refresh_ui) + # Apply output defaults in background (needs connection to settle first) + threading.Thread(target=self._apply_defaults, daemon=True).start() + + def _apply_defaults(self) -> None: + import time as _time + _time.sleep(0.5) # give Modbus connection a moment to establish + for sig in self._cfg.logical_io: + if sig.direction != "output": + continue + driver = self._io.driver(sig.device) + if driver is None: + continue + # Digital defaults + if sig.default_state is not None and sig.value_type == "bool": + ok = driver.write_output(sig.modbus_address, sig.default_state) + if ok: + self._output_state[sig.name] = sig.default_state + # Analog defaults + if sig.default_value is not None and sig.value_type == "int": + ok = driver.write_register(sig.modbus_address, sig.default_value) + if ok: + self._output_state[sig.name] = sig.default_value + + # ── Periodic refresh ───────────────────────────────────────────────────── + + def _refresh_ui(self) -> None: + snapshot = self._io.snapshot() + + # Inputs + input_snap = {n: s for n, s in snapshot.items() if n in self._input_names} + self.query_one(InputPanel).update_snapshot(input_snap) + + # Outputs (shadow state) + self.query_one(OutputPanel).update_output_state(self._output_state) + + # Sequences — get live run result + active_id = self._seq.active_run_id() + active_run: RunResult | None = None + if active_id: + active_run = self._seq.get_result(active_id) + + # Detect a newly-completed run and remember it + if not active_id and self._last_run_id: + result = self._seq.get_result(self._last_run_id) + if result and result.status not in ("pending", "running"): + self._last_result = result + self._last_run_id = None + + self.query_one(SequencePanel).update_run_state( + active_run = active_run, + last_result = self._last_result, + ) + + # Status bar + driver_map = {d["device_id"]: d for d in self._io.driver_status()} + combined: list[dict] = [] + for ps in self._io.poll_stats(): + did = ps["device_id"] + combined.append({**ps, "connected": driver_map.get(did, {}).get("connected", False)}) + self.query_one(StatusBar).update_stats(combined) + + # ── Output events ───────────────────────────────────────────────────────── + + @on(OutputPanel.ToggleOutput) + def handle_toggle(self, event: OutputPanel.ToggleOutput) -> None: + self._write_digital_output(event.signal, not event.current_value) + + @on(OutputPanel.WriteAnalog) + def handle_write_analog(self, event: OutputPanel.WriteAnalog) -> None: + self._write_analog_output(event.signal, event.value) + + @on(OutputPanel.AllOutputs) + def handle_all_outputs(self, event: OutputPanel.AllOutputs) -> None: + for name in self._output_names: + if self._output_type_map.get(name) == "bool": + self._write_digital_output(name, event.value) + + def _write_digital_output(self, signal_name: str, value: bool) -> None: + sig = self._cfg.signal(signal_name) + if sig is None: + self._flash(f"Unknown signal: {signal_name}") + return + driver = self._io.driver(sig.device) + if driver is None: + self._flash(f"No driver for {sig.device}") + return + + def do_write() -> None: + ok = driver.write_output(sig.modbus_address, value) + val_str = "ON" if value else "OFF" + if ok: + self._output_state[signal_name] = value + self._flash(f"{signal_name} → {val_str}") + else: + self._flash(f"WRITE FAILED: {signal_name} → {val_str}") + + threading.Thread(target=do_write, daemon=True).start() + + def _write_analog_output(self, signal_name: str, value: int) -> None: + sig = self._cfg.signal(signal_name) + if sig is None: + self._flash(f"Unknown signal: {signal_name}") + return + driver = self._io.driver(sig.device) + if driver is None: + self._flash(f"No driver for {sig.device}") + return + + def do_write() -> None: + ok = driver.write_register(sig.modbus_address, value) + if ok: + self._output_state[signal_name] = value + self._flash(f"{signal_name} → {value}") + else: + self._flash(f"WRITE FAILED: {signal_name} → {value}") + + threading.Thread(target=do_write, daemon=True).start() + + # ── Sequence events ─────────────────────────────────────────────────────── + + @on(SequencePanel.RunSequence) + def handle_run_sequence(self, event: SequencePanel.RunSequence) -> None: + active_id = self._seq.active_run_id() + if active_id: + self._flash(f"Busy: sequence already running") + return + + try: + run_id, started = self._seq.start(event.name) + except ValueError as e: + self._flash(str(e)) + return + + if not started: + self._flash("Busy: sequence already running") + return + + self._last_run_id = run_id + self._flash(f"Started: {event.name}") + + # ── Status flash ────────────────────────────────────────────────────────── + + def _flash(self, msg: str, duration: float = 4.0) -> None: + self.query_one(StatusBar).set_message(msg) + if self._status_clear_timer: + self._status_clear_timer.stop() + self._status_clear_timer = self.set_timer( + duration, lambda: self.query_one(StatusBar).set_message("") + ) + + # ── Actions ─────────────────────────────────────────────────────────────── + + def action_cycle_focus(self) -> None: + panels = [self.query_one(OutputPanel), self.query_one(SequencePanel)] + focused = self.focused + try: + idx = panels.index(focused) # type: ignore[arg-type] + panels[(idx + 1) % len(panels)].focus() + except ValueError: + panels[0].focus() + + def action_reconnect(self) -> None: + self._flash("Reconnecting…") + def do_reconnect() -> None: + for dev in self._cfg.devices: + driver = self._io.driver(dev.id) + if driver: + driver.connect() + self._flash("Reconnect done") + threading.Thread(target=do_reconnect, daemon=True).start() + + async def on_unmount(self) -> None: + self._io.stop() + + +# ───────────────────────────────────────────────────────────────────────────── +# Entry point +# ───────────────────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser(description="Arnold Terminator I/O debug TUI") + parser.add_argument( + "config", + nargs="?", + default=str(Path(__file__).parent / "config.yaml"), + help="Path to YAML config file (default: config.yaml)", + ) + args = parser.parse_args() + + try: + config = load_config(args.config) + except ConfigError as e: + print(f"Config error: {e}", file=sys.stderr) + sys.exit(1) + + registry = IORegistry(config) + + # The sequencer's on_output_write callback keeps _output_state in sync + # when a running sequence drives outputs. We store a reference to the + # dict and mutate it in-place from the callback (called from the + # sequencer's worker thread — dict writes are GIL-safe for simple + # key assignment). + output_state: dict[str, bool | int] = {} + for s in config.logical_io: + if s.direction == "output": + output_state[s.name] = 0 if s.value_type == "int" else False + + def on_output_write(signal_name: str, value: bool | int) -> None: + output_state[signal_name] = value + + sequencer = Sequencer( + config = config, + registry = registry, + log_path = Path(__file__).parent / "runs.log", + on_output_write = on_output_write, + ) + + app = TerminatorTUI(config=config, registry=registry, sequencer=sequencer) + # Share the same dict object so the callback and the TUI mutate the same state + app._output_state = output_state + + app.run() + + +if __name__ == "__main__": + main() diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..a7d889e --- /dev/null +++ b/web/app.js @@ -0,0 +1,600 @@ +/** + * Arnold — Terminator I/O Web Interface + * + * Vanilla JS, no build tools. Polls the REST API at ~20 Hz for I/O state + * and ~2 Hz for status/sequences. Renders into the DOM panels defined in + * index.html. + */ + +"use strict"; + +// ── State ────────────────────────────────────────────────────────────────── + +const state = { + signals: [], // from GET /config/signals (bootstrap) + ioSnapshot: {}, // from GET /io + status: null, // from GET /status + sequences: [], // from GET /sequences + sequenceCache: {}, // name -> full detail from GET /sequences/{name} + + // Output shadow state (what we last wrote) + outputState: {}, // signal_name -> bool|int + + // Analog pending edits + analogPending: {}, // signal_name -> int (before committed) + + // Active sequence run + activeRunId: null, + activeRun: null, // RunResult from GET /runs/{run_id} + lastResult: null, // last completed RunResult + + // Flash message + flashMsg: "", + flashTimer: null, +}; + +// Derived signal lists (populated after bootstrap) +let digitalInputs = []; +let analogInputs = []; +let digitalOutputs = []; +let analogOutputs = []; +let hasAnalogInputs = false; +let hasDigitalInputs = false; +let hasAnalogOutputs = false; +let hasDigitalOutputs = false; + +// ── API helpers ──────────────────────────────────────────────────────────── + +const API = ""; // same origin + +async function api(method, path, body) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + const res = await fetch(API + path, opts); + if (!res.ok) { + const text = await res.text(); + throw new Error(`${res.status} ${res.statusText}: ${text}`); + } + return res.json(); +} + +// ── Bootstrap ────────────────────────────────────────────────────────────── + +async function bootstrap() { + try { + // Load signal config + sequences in parallel + const [signals, sequences] = await Promise.all([ + api("GET", "/config/signals"), + api("GET", "/sequences"), + ]); + + state.signals = signals; + state.sequences = sequences; + + // Partition signals + digitalInputs = signals.filter(s => s.direction === "input" && s.value_type === "bool"); + analogInputs = signals.filter(s => s.direction === "input" && s.value_type === "int"); + digitalOutputs = signals.filter(s => s.direction === "output" && s.value_type === "bool"); + analogOutputs = signals.filter(s => s.direction === "output" && s.value_type === "int"); + + hasDigitalInputs = digitalInputs.length > 0; + hasAnalogInputs = analogInputs.length > 0; + hasDigitalOutputs = digitalOutputs.length > 0; + hasAnalogOutputs = analogOutputs.length > 0; + + // Init output shadow state + for (const s of digitalOutputs) { + state.outputState[s.name] = s.default_state ?? false; + } + for (const s of analogOutputs) { + state.outputState[s.name] = s.default_value ?? 0; + } + + // Pre-fetch full sequence details + for (const seq of sequences) { + api("GET", `/sequences/${encodeURIComponent(seq.name)}`).then(detail => { + state.sequenceCache[seq.name] = detail; + }); + } + + buildInputPanel(); + buildOutputPanel(); + buildSequencePanel(); + + // Start poll loops + pollIO(); + pollStatus(); + pollRun(); + + } catch (err) { + flash("Bootstrap failed: " + err.message); + console.error("Bootstrap error:", err); + // Retry in 3 seconds + setTimeout(bootstrap, 3000); + } +} + +// ── Polling loops ────────────────────────────────────────────────────────── + +function pollIO() { + api("GET", "/io") + .then(data => { + state.ioSnapshot = data; + renderInputs(); + renderOutputValues(); + }) + .catch(() => {}) + .finally(() => setTimeout(pollIO, 50)); // ~20 Hz +} + +function pollStatus() { + api("GET", "/status") + .then(data => { + state.status = data; + renderStatusBar(); + + // Track active run + if (data.active_run && !state.activeRunId) { + state.activeRunId = data.active_run; + } + }) + .catch(() => {}) + .finally(() => setTimeout(pollStatus, 500)); // 2 Hz +} + +function pollRun() { + if (state.activeRunId) { + api("GET", `/runs/${state.activeRunId}`) + .then(run => { + state.activeRun = run; + + if (run.status !== "pending" && run.status !== "running") { + // Run completed + state.lastResult = run; + state.activeRunId = null; + state.activeRun = null; + renderSequenceIdle(); + } else { + renderSequenceRunning(); + } + }) + .catch(() => {}); + } + setTimeout(pollRun, 100); +} + +// ── Status bar ───────────────────────────────────────────────────────────── + +function renderStatusBar() { + const el = document.getElementById("status-devices"); + if (!state.status) { el.innerHTML = "connecting..."; return; } + + const devs = state.status.devices || []; + const polls = state.status.poll_stats || []; + const pollMap = {}; + for (const p of polls) pollMap[p.device_id] = p; + + el.innerHTML = devs.map(d => { + const p = pollMap[d.device_id] || {}; + const dot = d.connected ? "connected" : "disconnected"; + const hz = (p.achieved_hz || 0).toFixed(0); + const err = p.error_count || 0; + return `
+ + ${d.device_id} + ${hz} Hz + err=${err} +
`; + }).join(""); +} + +// ── Input panel ──────────────────────────────────────────────────────────── + +function buildInputPanel() { + const diHeader = document.querySelector("#digital-inputs .sub-header"); + const aiHeader = document.querySelector("#analog-inputs .sub-header"); + const noInputs = document.getElementById("no-inputs"); + + if (!hasDigitalInputs && !hasAnalogInputs) { + noInputs.classList.remove("hidden"); + return; + } + noInputs.classList.add("hidden"); + + // Show sub-headers only when both types present + if (hasDigitalInputs && hasAnalogInputs) { + diHeader.classList.remove("hidden"); + aiHeader.classList.remove("hidden"); + } + + // Build digital input rows + const diList = document.getElementById("digital-input-list"); + diList.innerHTML = digitalInputs.map(s => ` +
+ + ${s.name} + OFF +
+ `).join(""); + + // Build analog input rows + const aiList = document.getElementById("analog-input-list"); + aiList.innerHTML = analogInputs.map(s => ` +
+ ${s.name} + 0 +
+ `).join(""); +} + +function renderInputs() { + // Digital inputs + for (const s of digitalInputs) { + const row = document.getElementById(`di-${s.name}`); + if (!row) continue; + const io = state.ioSnapshot[s.name]; + const dot = row.querySelector(".indicator"); + const val = row.querySelector(".signal-value"); + + if (!io || io.stale) { + dot.className = "indicator stale"; + val.className = "signal-value stale"; + val.textContent = "?"; + } else if (io.value) { + dot.className = "indicator on"; + val.className = "signal-value on"; + val.textContent = "ON"; + } else { + dot.className = "indicator off"; + val.className = "signal-value off"; + val.textContent = "OFF"; + } + } + + // Analog inputs + for (const s of analogInputs) { + const row = document.getElementById(`ai-${s.name}`); + if (!row) continue; + const io = state.ioSnapshot[s.name]; + const val = row.querySelector(".signal-value"); + + if (!io || io.stale) { + val.className = "signal-value stale"; + val.textContent = "?"; + } else { + val.className = "signal-value analog"; + val.textContent = String(io.value); + } + } +} + +// ── Output panel ─────────────────────────────────────────────────────────── + +function buildOutputPanel() { + const doHeader = document.querySelector("#digital-outputs .sub-header"); + const aoHeader = document.querySelector("#analog-outputs .sub-header"); + const noOutputs = document.getElementById("no-outputs"); + const bulkDiv = document.getElementById("digital-bulk"); + + if (!hasDigitalOutputs && !hasAnalogOutputs) { + noOutputs.classList.remove("hidden"); + return; + } + noOutputs.classList.add("hidden"); + + // Show sub-headers only when both types present + if (hasDigitalOutputs && hasAnalogOutputs) { + doHeader.classList.remove("hidden"); + aoHeader.classList.remove("hidden"); + } + + // Bulk buttons + if (hasDigitalOutputs) { + bulkDiv.classList.remove("hidden"); + document.getElementById("btn-all-off").addEventListener("click", () => allDigitalOutputs(false)); + document.getElementById("btn-all-on").addEventListener("click", () => allDigitalOutputs(true)); + } + + // Digital output rows + const doList = document.getElementById("digital-output-list"); + doList.innerHTML = digitalOutputs.map(s => ` +
+ + ${s.name} + OFF +
+ `).join(""); + + // Click to toggle + for (const s of digitalOutputs) { + document.getElementById(`do-${s.name}`).addEventListener("click", () => { + const cur = state.outputState[s.name]; + writeOutput(s.name, !cur); + }); + } + + // Analog output rows + const aoList = document.getElementById("analog-output-list"); + aoList.innerHTML = analogOutputs.map(s => ` +
+ ${s.name} +
+ + + + +
+
+ `).join(""); + + // Analog control events + for (const s of analogOutputs) { + const input = aoList.querySelector(`input[data-signal="${s.name}"]`); + const minus = aoList.querySelector(`.ao-minus[data-signal="${s.name}"]`); + const plus = aoList.querySelector(`.ao-plus[data-signal="${s.name}"]`); + const write = aoList.querySelector(`.ao-write[data-signal="${s.name}"]`); + + minus.addEventListener("click", () => { + const cur = parseInt(input.value) || 0; + const nv = Math.max(0, cur - 100); + input.value = nv; + markAnalogPending(s.name, nv, input); + }); + + plus.addEventListener("click", () => { + const cur = parseInt(input.value) || 0; + const nv = Math.min(65535, cur + 100); + input.value = nv; + markAnalogPending(s.name, nv, input); + }); + + input.addEventListener("input", () => { + let v = parseInt(input.value); + if (isNaN(v)) v = 0; + v = Math.max(0, Math.min(65535, v)); + markAnalogPending(s.name, v, input); + }); + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + const v = parseInt(input.value) || 0; + writeOutput(s.name, Math.max(0, Math.min(65535, v))); + } + }); + + write.addEventListener("click", () => { + const v = parseInt(input.value) || 0; + writeOutput(s.name, Math.max(0, Math.min(65535, v))); + }); + } +} + +function markAnalogPending(name, value, inputEl) { + state.analogPending[name] = value; + if (value !== state.outputState[name]) { + inputEl.classList.add("pending"); + } else { + inputEl.classList.remove("pending"); + } +} + +function renderOutputValues() { + // Digital outputs — use shadow state, NOT polled IO + for (const s of digitalOutputs) { + const row = document.getElementById(`do-${s.name}`); + if (!row) continue; + const val = state.outputState[s.name]; + const dot = row.querySelector(".indicator"); + const txt = row.querySelector(".signal-value"); + + if (val) { + dot.className = "indicator on"; + txt.className = "signal-value on"; + txt.textContent = "ON"; + } else { + dot.className = "indicator off"; + txt.className = "signal-value off"; + txt.textContent = "OFF"; + } + } + + // Analog outputs — update input value only if not pending + for (const s of analogOutputs) { + const input = document.querySelector(`input.ao-input[data-signal="${s.name}"]`); + if (!input) continue; + if (!(s.name in state.analogPending)) { + input.value = state.outputState[s.name] || 0; + input.classList.remove("pending"); + } + } +} + +// ── Output writes ────────────────────────────────────────────────────────── + +async function writeOutput(name, value) { + try { + await api("POST", `/io/${encodeURIComponent(name)}/write`, { value }); + state.outputState[name] = value; + delete state.analogPending[name]; + + // Clear pending style on analog input + const input = document.querySelector(`input.ao-input[data-signal="${name}"]`); + if (input) { + input.value = value; + input.classList.remove("pending"); + } + + if (typeof value === "boolean") { + flash(`${name} \u2192 ${value ? "ON" : "OFF"}`); + } else { + flash(`${name} \u2192 ${value}`); + } + } catch (err) { + flash(`WRITE FAILED: ${name} \u2014 ${err.message}`); + } +} + +async function allDigitalOutputs(value) { + for (const s of digitalOutputs) { + writeOutput(s.name, value); + } +} + +// ── Sequence panel ───────────────────────────────────────────────────────── + +function buildSequencePanel() { + renderSequenceIdle(); +} + +function renderSequenceIdle() { + const idle = document.getElementById("sequence-idle"); + const running = document.getElementById("sequence-running"); + idle.classList.remove("hidden"); + running.classList.add("hidden"); + + const list = document.getElementById("sequence-list"); + list.innerHTML = state.sequences.map(seq => ` +
+
+
${esc(seq.name)}
+ ${seq.description ? `
${esc(seq.description)}
` : ""} +
+ ${seq.steps} steps + +
+ `).join(""); + + // Bind run buttons + for (const btn of list.querySelectorAll(".seq-run-btn")) { + btn.addEventListener("click", () => runSequence(btn.dataset.seq)); + } + + // Last run summary + const summaryEl = document.getElementById("last-run-summary"); + if (state.lastResult) { + const r = state.lastResult; + const cls = r.status === "success" ? "run-success" : "run-failed"; + let html = `${esc(r.sequence_name)} \u2192 ${r.status.toUpperCase()}`; + html += ` ${r.steps_completed}/${r.total_steps} steps · ${r.duration_ms} ms`; + if (r.failed_step) { + html += `
\u2717 step ${r.failed_step.step_index} (${r.failed_step.t_ms} ms): ${esc(r.failed_step.detail)}`; + } + summaryEl.innerHTML = html; + summaryEl.classList.remove("hidden"); + } else { + summaryEl.classList.add("hidden"); + } +} + +function renderSequenceRunning() { + const idle = document.getElementById("sequence-idle"); + const running = document.getElementById("sequence-running"); + idle.classList.add("hidden"); + running.classList.remove("hidden"); + + const run = state.activeRun; + if (!run) return; + + const detail = state.sequenceCache[run.sequence_name]; + + // Header + document.getElementById("run-header").textContent = + `\u25B6 ${run.sequence_name} ${run.steps_completed}/${run.total_steps}`; + + // Progress bar + const pct = run.total_steps ? (run.steps_completed / run.total_steps * 100) : 0; + document.getElementById("run-progress-fill").style.width = pct + "%"; + + // Step list + const stepList = document.getElementById("run-step-list"); + if (!detail) { + stepList.innerHTML = "
(loading steps...)
"; + return; + } + + stepList.innerHTML = detail.steps.map((step, i) => { + let cls, icon; + if (i === run.current_step_index) { + cls = "current"; icon = "\u25B6"; + } else if (i < run.steps_completed) { + cls = "completed"; icon = "\u2713"; + } else { + cls = "pending-step"; icon = "\u00B7"; + } + + const tStr = (step.t_ms / 1000).toFixed(1) + "s"; + const action = formatStep(step); + + return `
+ ${icon} + ${tStr} + ${esc(action)} +
`; + }).join(""); +} + +function formatStep(step) { + if (step.action === "set_output") { + if (step.value !== null && step.value !== undefined) { + return `set ${step.signal} \u2192 ${step.value}`; + } + return `set ${step.signal} \u2192 ${step.state ? "ON" : "OFF"}`; + } + if (step.action === "check_input") { + if (step.expected_value !== null && step.expected_value !== undefined) { + const tol = step.tolerance ? `\u00B1${step.tolerance}` : ""; + return `chk ${step.signal} == ${step.expected_value}${tol}`; + } + return `chk ${step.signal} == ${step.expected ? "ON" : "OFF"}`; + } + if (step.action === "wait_input") { + const tout = step.timeout_ms ? `${step.timeout_ms} ms` : "?"; + if (step.expected_value !== null && step.expected_value !== undefined) { + const tol = step.tolerance ? `\u00B1${step.tolerance}` : ""; + return `wait ${step.signal} == ${step.expected_value}${tol} (timeout ${tout})`; + } + return `wait ${step.signal} == ${step.expected ? "ON" : "OFF"} (timeout ${tout})`; + } + return `${step.action} ${step.signal}`; +} + +async function runSequence(name) { + if (state.activeRunId) { + flash("Busy: sequence already running"); + return; + } + try { + const res = await api("POST", `/sequences/${encodeURIComponent(name)}/run`); + state.activeRunId = res.run_id; + flash(`Started: ${name}`); + renderSequenceRunning(); + } catch (err) { + flash(`Failed to start: ${err.message}`); + } +} + +// ── Flash message ────────────────────────────────────────────────────────── + +function flash(msg, duration = 4000) { + const el = document.getElementById("status-message"); + el.textContent = msg; + if (state.flashTimer) clearTimeout(state.flashTimer); + state.flashTimer = setTimeout(() => { el.textContent = ""; }, duration); +} + +// ── Utilities ────────────────────────────────────────────────────────────── + +function esc(str) { + const d = document.createElement("div"); + d.textContent = str; + return d.innerHTML; +} + +// ── Start ────────────────────────────────────────────────────────────────── + +document.addEventListener("DOMContentLoaded", bootstrap); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..1a0515a --- /dev/null +++ b/web/index.html @@ -0,0 +1,71 @@ + + + + + + Arnold — Terminator I/O + + + + +
+
+
+
+ + +
+ +
+

Inputs

+
+ +
+
+
+ +
+
+
(none)
+
+ + +
+

Outputs

+
+ + +
+
+
+ +
+
+
(none)
+
+ + +
+

Sequences

+
+
+ +
+ +
+
+ +
+ Arnold — Terminator I/O Server +
+ + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..fa61a64 --- /dev/null +++ b/web/style.css @@ -0,0 +1,463 @@ +/* Arnold — Terminator I/O Web Interface + Dark theme, terminal-inspired, responsive. */ + +:root { + --bg: #1a1a2e; + --bg-panel: #16213e; + --bg-hover: #1e2d4a; + --border: #2a3a5c; + --text: #c8d6e5; + --text-dim: #6b7b8d; + --green: #2ecc71; + --green-dim: #1a7a42; + --red: #e74c3c; + --yellow: #f39c12; + --cyan: #00cec9; + --accent: #6c5ce7; + --accent-light:#a29bfe; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace; + font-size: 14px; + background: var(--bg); + color: var(--text); +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* ── Status bar ─────────────────────────────────────────────── */ + +#status-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 16px; + background: #0f1527; + border-bottom: 1px solid var(--border); + font-size: 13px; + flex-shrink: 0; + gap: 16px; + flex-wrap: wrap; +} + +#status-devices { + display: flex; + gap: 20px; + flex-wrap: wrap; +} + +.device-status { + display: flex; + align-items: center; + gap: 6px; +} + +.device-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} +.device-dot.connected { background: var(--green); } +.device-dot.disconnected { background: var(--red); } + +#status-message { + color: var(--yellow); + font-size: 12px; + min-height: 1em; +} + +/* ── Main panels ────────────────────────────────────────────── */ + +#panels { + display: flex; + flex: 1; + gap: 0; + overflow: hidden; +} + +.panel { + flex: 1; + border-right: 1px solid var(--border); + padding: 12px 16px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.panel:last-child { + border-right: none; +} + +.panel-wide { + flex: 2; +} + +.panel h2 { + font-size: 15px; + font-weight: 600; + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); + color: var(--text); + flex-shrink: 0; +} + +.sub-header { + font-size: 11px; + font-weight: 400; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 10px 0 6px 0; + padding-bottom: 3px; + border-bottom: 1px solid var(--border); +} + +.empty-msg { + color: var(--text-dim); + font-style: italic; + padding: 8px 0; +} + +.hidden { display: none !important; } + +/* ── Signal rows ────────────────────────────────────────────── */ + +.signal-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.signal-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: 4px; + min-height: 30px; +} + +.signal-row:hover { + background: var(--bg-hover); +} + +/* Indicator dot for digital signals */ +.indicator { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.indicator.on { background: var(--green); box-shadow: 0 0 6px var(--green); } +.indicator.off { background: #3a4a5c; border: 1px solid #4a5a6c; } +.indicator.stale { background: #5a5a5a; } + +.signal-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.signal-value { + font-weight: 600; + min-width: 40px; + text-align: right; +} +.signal-value.on { color: var(--green); } +.signal-value.off { color: var(--text-dim); } +.signal-value.stale { color: var(--text-dim); } +.signal-value.analog { color: var(--cyan); } + +/* ── Output controls ────────────────────────────────────────── */ + +.output-row { + cursor: pointer; + user-select: none; +} + +.output-row:active { + background: #2a3d5c; +} + +.bulk-actions { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.bulk-actions button, button.seq-run-btn { + padding: 4px 12px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: 12px; +} + +.bulk-actions button:hover, button.seq-run-btn:hover { + background: var(--bg-hover); + border-color: var(--accent); +} + +.bulk-actions button:active, button.seq-run-btn:active { + background: var(--accent); +} + +/* Analog output controls */ +.analog-controls { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.analog-controls button { + width: 28px; + height: 28px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: 16px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.analog-controls button:hover { + border-color: var(--cyan); + background: var(--bg-hover); +} + +.analog-controls button:active { + background: var(--cyan); + color: var(--bg); +} + +.analog-controls input { + width: 70px; + padding: 3px 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--cyan); + font-family: inherit; + font-size: 13px; + text-align: right; + border-radius: 4px; +} + +.analog-controls input:focus { + border-color: var(--cyan); + outline: none; +} + +.analog-controls .write-btn { + font-size: 12px; + width: auto; + padding: 0 8px; + color: var(--cyan); +} + +.analog-controls .write-btn:hover { + background: var(--cyan); + color: var(--bg); +} + +.analog-controls .pending { + border-color: var(--yellow); + color: var(--yellow); +} + +/* ── Sequence panel ─────────────────────────────────────────── */ + +.seq-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 4px; + margin-bottom: 4px; +} + +.seq-item:hover { + background: var(--bg-hover); +} + +.seq-name { + font-weight: 600; + flex: 1; +} + +.seq-meta { + color: var(--text-dim); + font-size: 12px; +} + +.seq-desc { + color: var(--text-dim); + font-size: 12px; + padding: 0 10px 6px 10px; +} + +button.seq-run-btn { + border-color: var(--accent); + color: var(--accent-light); +} + +button.seq-run-btn:hover { + background: var(--accent); + color: white; +} + +button.seq-run-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Run progress */ +#run-header { + font-weight: 600; + color: var(--yellow); + margin-bottom: 8px; + font-size: 15px; +} + +#run-progress-bar { + height: 6px; + background: #2a3a5c; + border-radius: 3px; + margin-bottom: 12px; + overflow: hidden; +} + +#run-progress-fill { + height: 100%; + background: var(--yellow); + border-radius: 3px; + transition: width 0.2s ease; + width: 0%; +} + +.step-row { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 8px; + border-radius: 3px; + font-size: 13px; +} + +.step-row.current { + background: rgba(243, 156, 18, 0.15); + color: var(--yellow); + font-weight: 600; +} + +.step-row.completed { + color: var(--green-dim); +} + +.step-row.pending-step { + color: var(--text-dim); +} + +.step-icon { + width: 16px; + text-align: center; + flex-shrink: 0; +} + +.step-time { + width: 55px; + text-align: right; + flex-shrink: 0; +} + +.step-action { + flex: 1; +} + +/* Last run summary */ +#last-run-summary { + margin-top: 16px; + padding: 10px; + border-top: 1px solid var(--border); + font-size: 13px; +} + +.run-success { color: var(--green); } +.run-failed { color: var(--red); } +.run-error { color: var(--red); } + +/* ── Footer ─────────────────────────────────────────────────── */ + +#footer { + padding: 4px 16px; + background: #0f1527; + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--text-dim); + text-align: center; + flex-shrink: 0; +} + +/* ── Responsive ─────────────────────────────────────────────── */ + +/* Tablet: stack outputs below inputs, sequences full width below */ +@media (max-width: 1024px) { + #panels { + flex-wrap: wrap; + } + .panel { + flex: 1 1 45%; + min-width: 280px; + border-right: none; + border-bottom: 1px solid var(--border); + max-height: 50vh; + } + .panel-wide { + flex: 1 1 100%; + max-height: none; + } +} + +/* Phone: single column */ +@media (max-width: 640px) { + #panels { + flex-direction: column; + } + .panel { + flex: none; + max-height: none; + border-right: none; + border-bottom: 1px solid var(--border); + } + .panel-wide { + flex: none; + } + #status-bar { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + .signal-row { + padding: 6px 8px; + } +}