This commit is contained in:
2026-03-02 17:48:55 -05:00
commit 75678fce4d
27 changed files with 5354 additions and 0 deletions

162
README.md Normal file
View File

@@ -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://<pi-ip>: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 | 07 |
| 2 | T1H-08TDS (input) | 8 | 815 |
| 3 | T1K-16TD2-1 (output) | 16 | **1631** |
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.

Binary file not shown.

Binary file not shown.

2
arnold/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# arnold — Terminator I/O server package
__version__ = "0.1.0"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

263
arnold/api.py Normal file
View File

@@ -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 065535, 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

537
arnold/config.py Normal file
View File

@@ -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

289
arnold/module_types.py Normal file
View File

@@ -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

494
arnold/sequencer.py Normal file
View File

@@ -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

663
arnold/terminator_io.py Normal file
View File

@@ -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
(065535), 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 (065535).
"""
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]

329
config.yaml Normal file
View File

@@ -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, 18 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

149
config_with_outputs.yaml Normal file
View File

@@ -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 116 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 }

281
probe_terminator.py Normal file
View File

@@ -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()

12
runs.log Normal file
View File

@@ -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": ""}

156
server.py Normal file
View File

@@ -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://<host>:<port>/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()

883
tui.py Normal file
View File

@@ -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()

600
web/app.js Normal file
View File

@@ -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 = "<span style='color:var(--text-dim)'>connecting...</span>"; 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 `<div class="device-status">
<span class="device-dot ${dot}"></span>
<span>${d.device_id}</span>
<span style="color:var(--text-dim)">${hz} Hz</span>
<span style="color:var(--text-dim)">err=${err}</span>
</div>`;
}).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 => `
<div class="signal-row" id="di-${s.name}">
<span class="indicator off"></span>
<span class="signal-name">${s.name}</span>
<span class="signal-value off">OFF</span>
</div>
`).join("");
// Build analog input rows
const aiList = document.getElementById("analog-input-list");
aiList.innerHTML = analogInputs.map(s => `
<div class="signal-row" id="ai-${s.name}">
<span class="signal-name">${s.name}</span>
<span class="signal-value analog">0</span>
</div>
`).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 => `
<div class="signal-row output-row" id="do-${s.name}" data-signal="${s.name}">
<span class="indicator off"></span>
<span class="signal-name">${s.name}</span>
<span class="signal-value off">OFF</span>
</div>
`).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 => `
<div class="signal-row" id="ao-${s.name}">
<span class="signal-name">${s.name}</span>
<div class="analog-controls">
<button class="ao-minus" data-signal="${s.name}" title="Decrease">&minus;</button>
<input type="number" class="ao-input" data-signal="${s.name}"
min="0" max="65535" value="${state.outputState[s.name] || 0}">
<button class="ao-plus" data-signal="${s.name}" title="Increase">+</button>
<button class="write-btn ao-write" data-signal="${s.name}">Write</button>
</div>
</div>
`).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 => `
<div class="seq-item">
<div style="flex:1">
<div class="seq-name">${esc(seq.name)}</div>
${seq.description ? `<div class="seq-desc">${esc(seq.description)}</div>` : ""}
</div>
<span class="seq-meta">${seq.steps} steps</span>
<button class="seq-run-btn" data-seq="${esc(seq.name)}"
${state.activeRunId ? "disabled" : ""}>Run</button>
</div>
`).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 = `<span class="${cls}">${esc(r.sequence_name)} \u2192 ${r.status.toUpperCase()}</span>`;
html += ` <span style="color:var(--text-dim)">${r.steps_completed}/${r.total_steps} steps &middot; ${r.duration_ms} ms</span>`;
if (r.failed_step) {
html += `<br><span class="run-failed">\u2717 step ${r.failed_step.step_index} (${r.failed_step.t_ms} ms): ${esc(r.failed_step.detail)}</span>`;
}
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 = "<div style='color:var(--text-dim)'>(loading steps...)</div>";
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 `<div class="step-row ${cls}">
<span class="step-icon">${icon}</span>
<span class="step-time">${tStr}</span>
<span class="step-action">${esc(action)}</span>
</div>`;
}).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);

71
web/index.html Normal file
View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arnold &mdash; Terminator I/O</title>
<link rel="stylesheet" href="/web/style.css">
</head>
<body>
<!-- Status bar -->
<header id="status-bar">
<div id="status-devices"></div>
<div id="status-message"></div>
</header>
<!-- Main 3-panel layout -->
<main id="panels">
<!-- Inputs panel -->
<section id="input-panel" class="panel">
<h2>Inputs</h2>
<div id="digital-inputs">
<h3 class="sub-header hidden">Digital</h3>
<div id="digital-input-list" class="signal-list"></div>
</div>
<div id="analog-inputs">
<h3 class="sub-header hidden">Analog</h3>
<div id="analog-input-list" class="signal-list"></div>
</div>
<div id="no-inputs" class="empty-msg">(none)</div>
</section>
<!-- Outputs panel -->
<section id="output-panel" class="panel">
<h2>Outputs</h2>
<div id="digital-outputs">
<h3 class="sub-header hidden">Digital</h3>
<div class="bulk-actions hidden" id="digital-bulk">
<button id="btn-all-off" title="All digital outputs OFF">All OFF</button>
<button id="btn-all-on" title="All digital outputs ON">All ON</button>
</div>
<div id="digital-output-list" class="signal-list"></div>
</div>
<div id="analog-outputs">
<h3 class="sub-header hidden">Analog</h3>
<div id="analog-output-list" class="signal-list"></div>
</div>
<div id="no-outputs" class="empty-msg">(none)</div>
</section>
<!-- Sequences panel -->
<section id="sequence-panel" class="panel panel-wide">
<h2>Sequences</h2>
<div id="sequence-idle">
<div id="sequence-list"></div>
<div id="last-run-summary" class="hidden"></div>
</div>
<div id="sequence-running" class="hidden">
<div id="run-header"></div>
<div id="run-progress-bar"><div id="run-progress-fill"></div></div>
<div id="run-step-list"></div>
</div>
</section>
</main>
<footer id="footer">
Arnold &mdash; Terminator I/O Server
</footer>
<script src="/web/app.js"></script>
</body>
</html>

463
web/style.css Normal file
View File

@@ -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;
}
}