commit 75678fce4d942719a02d9d0edf97bf9a2e851e81 Author: noisedestroyers Date: Mon Mar 2 17:48:55 2026 -0500 first 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 0000000..780628d Binary files /dev/null and b/__pycache__/server.cpython-311.pyc differ diff --git a/__pycache__/tui.cpython-311.pyc b/__pycache__/tui.cpython-311.pyc new file mode 100644 index 0000000..5bb31c1 Binary files /dev/null and b/__pycache__/tui.cpython-311.pyc differ diff --git a/arnold/__init__.py b/arnold/__init__.py new file mode 100644 index 0000000..a770a4e --- /dev/null +++ b/arnold/__init__.py @@ -0,0 +1,2 @@ +# arnold — Terminator I/O server package +__version__ = "0.1.0" diff --git a/arnold/__pycache__/__init__.cpython-311.pyc b/arnold/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..6add43d Binary files /dev/null and b/arnold/__pycache__/__init__.cpython-311.pyc differ diff --git a/arnold/__pycache__/api.cpython-311.pyc b/arnold/__pycache__/api.cpython-311.pyc new file mode 100644 index 0000000..7d64360 Binary files /dev/null and b/arnold/__pycache__/api.cpython-311.pyc differ diff --git a/arnold/__pycache__/config.cpython-311.pyc b/arnold/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..dcb8357 Binary files /dev/null and b/arnold/__pycache__/config.cpython-311.pyc differ diff --git a/arnold/__pycache__/io_driver.cpython-311.pyc b/arnold/__pycache__/io_driver.cpython-311.pyc new file mode 100644 index 0000000..7d1c75a Binary files /dev/null and b/arnold/__pycache__/io_driver.cpython-311.pyc differ diff --git a/arnold/__pycache__/io_state.cpython-311.pyc b/arnold/__pycache__/io_state.cpython-311.pyc new file mode 100644 index 0000000..7585f20 Binary files /dev/null and b/arnold/__pycache__/io_state.cpython-311.pyc differ diff --git a/arnold/__pycache__/module_types.cpython-311.pyc b/arnold/__pycache__/module_types.cpython-311.pyc new file mode 100644 index 0000000..02d3d7f Binary files /dev/null and b/arnold/__pycache__/module_types.cpython-311.pyc differ diff --git a/arnold/__pycache__/poller.cpython-311.pyc b/arnold/__pycache__/poller.cpython-311.pyc new file mode 100644 index 0000000..1fd0dfe Binary files /dev/null and b/arnold/__pycache__/poller.cpython-311.pyc differ diff --git a/arnold/__pycache__/sequencer.cpython-311.pyc b/arnold/__pycache__/sequencer.cpython-311.pyc new file mode 100644 index 0000000..b2e9d9e Binary files /dev/null and b/arnold/__pycache__/sequencer.cpython-311.pyc differ diff --git a/arnold/__pycache__/terminator_io.cpython-311.pyc b/arnold/__pycache__/terminator_io.cpython-311.pyc new file mode 100644 index 0000000..5eb93c3 Binary files /dev/null and b/arnold/__pycache__/terminator_io.cpython-311.pyc differ 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; + } +}