first
This commit is contained in:
162
README.md
Normal file
162
README.md
Normal 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 | 0–7 |
|
||||
| 2 | T1H-08TDS (input) | 8 | 8–15 |
|
||||
| 3 | T1K-16TD2-1 (output) | 16 | **16–31** |
|
||||
|
||||
FC05/FC15 output writes must use these unified addresses.
|
||||
The config loader computes `modbus_address` for every module and signal
|
||||
automatically — you never write raw addresses in YAML.
|
||||
|
||||
### FC02 input reads start at address 0
|
||||
|
||||
FC02 (read discrete inputs) returns only input bits, starting at bit index 0,
|
||||
regardless of where inputs sit in the unified space. The poll thread reads
|
||||
`total_input_points` bits from FC02 address 0. Because `modbus_address` for
|
||||
input signals equals their FC02 bit index (inputs occupy the lowest slots in
|
||||
practice), no remapping is needed.
|
||||
|
||||
### No exception on out-of-range addresses
|
||||
|
||||
The EBC100 returns zeros for any FC02 read address beyond the installed
|
||||
modules — it never raises Modbus exception code 2 (illegal data address).
|
||||
Module presence **cannot** be auto-detected from protocol errors.
|
||||
The `modules` list in the config is authoritative.
|
||||
|
||||
### FC05 write echo
|
||||
|
||||
`write_coil` (FC05) echoes back `True` for any address, even unmapped ones.
|
||||
There is no error feedback for writes to non-existent output points.
|
||||
Config validation at startup prevents invalid addresses from being used.
|
||||
|
||||
### Unit ID is ignored
|
||||
|
||||
The EBC100 accepts and echoes back any Modbus unit/slave ID over TCP.
|
||||
Set `unit_id: 1` in the config (standard default).
|
||||
|
||||
### No unsolicited push
|
||||
|
||||
Modbus TCP is a strictly polled protocol; the EBC100 has no push capability.
|
||||
The server polls at `poll_interval_ms` (default 50 ms = 20 Hz).
|
||||
At 24 input points a single FC02 read takes ~1 ms on a local network.
|
||||
|
||||
### Web interface
|
||||
|
||||
The EBC100 hosts a minimal HTTP server on port 80 (firmware by Host Engineering).
|
||||
It exposes IP/subnet/gateway config and serial port mode only — no I/O data.
|
||||
Port 443, 503, and 8080 are closed. UDP port 502 is not active.
|
||||
BIN
__pycache__/server.cpython-311.pyc
Normal file
BIN
__pycache__/server.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/tui.cpython-311.pyc
Normal file
BIN
__pycache__/tui.cpython-311.pyc
Normal file
Binary file not shown.
2
arnold/__init__.py
Normal file
2
arnold/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# arnold — Terminator I/O server package
|
||||
__version__ = "0.1.0"
|
||||
BIN
arnold/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
arnold/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
arnold/__pycache__/api.cpython-311.pyc
Normal file
BIN
arnold/__pycache__/api.cpython-311.pyc
Normal file
Binary file not shown.
BIN
arnold/__pycache__/config.cpython-311.pyc
Normal file
BIN
arnold/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
arnold/__pycache__/io_driver.cpython-311.pyc
Normal file
BIN
arnold/__pycache__/io_driver.cpython-311.pyc
Normal file
Binary file not shown.
BIN
arnold/__pycache__/io_state.cpython-311.pyc
Normal file
BIN
arnold/__pycache__/io_state.cpython-311.pyc
Normal file
Binary file not shown.
BIN
arnold/__pycache__/module_types.cpython-311.pyc
Normal file
BIN
arnold/__pycache__/module_types.cpython-311.pyc
Normal file
Binary file not shown.
BIN
arnold/__pycache__/poller.cpython-311.pyc
Normal file
BIN
arnold/__pycache__/poller.cpython-311.pyc
Normal file
Binary file not shown.
BIN
arnold/__pycache__/sequencer.cpython-311.pyc
Normal file
BIN
arnold/__pycache__/sequencer.cpython-311.pyc
Normal file
Binary file not shown.
BIN
arnold/__pycache__/terminator_io.cpython-311.pyc
Normal file
BIN
arnold/__pycache__/terminator_io.cpython-311.pyc
Normal file
Binary file not shown.
263
arnold/api.py
Normal file
263
arnold/api.py
Normal 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 0–65535, got {val}")
|
||||
ok = driver.write_register(sig.modbus_address, val)
|
||||
else:
|
||||
val = bool(req.value)
|
||||
ok = driver.write_output(sig.modbus_address, val)
|
||||
|
||||
if not ok:
|
||||
raise HTTPException(502, f"Write failed for {signal_name!r}")
|
||||
|
||||
return {"signal": signal_name, "value": val, "ok": True}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /config/signals — full signal metadata for UI bootstrap
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.get("/config/signals", summary="All signal metadata from config")
|
||||
def get_config_signals() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"name": s.name,
|
||||
"direction": s.direction,
|
||||
"category": s.category,
|
||||
"value_type": s.value_type,
|
||||
"modbus_space": s.modbus_space,
|
||||
"device": s.device,
|
||||
"slot": s.slot,
|
||||
"point": s.point,
|
||||
"modbus_address": s.modbus_address,
|
||||
"default_state": s.default_state,
|
||||
"default_value": s.default_value,
|
||||
}
|
||||
for s in ctx.config.logical_io
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /sequences
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.get("/sequences", summary="List all sequences")
|
||||
def list_sequences() -> list[dict]:
|
||||
return [
|
||||
{"name": seq.name, "description": seq.description, "steps": len(seq.steps)}
|
||||
for seq in ctx.config.sequences
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /sequences/{name}
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.get("/sequences/{name}", summary="Sequence detail")
|
||||
def get_sequence(name: str) -> dict:
|
||||
seq = ctx.config.sequence(name)
|
||||
if seq is None:
|
||||
raise HTTPException(404, f"Unknown sequence: {name!r}")
|
||||
return {
|
||||
"name": seq.name,
|
||||
"description": seq.description,
|
||||
"steps": [
|
||||
{
|
||||
"t_ms": step.t_ms,
|
||||
"action": step.action,
|
||||
"signal": step.signal,
|
||||
# Digital fields (None for analog)
|
||||
"state": step.state,
|
||||
"expected": step.expected,
|
||||
# Analog fields (None for digital)
|
||||
"value": step.value,
|
||||
"expected_value": step.expected_value,
|
||||
"tolerance": step.tolerance,
|
||||
# wait_input
|
||||
"timeout_ms": step.timeout_ms,
|
||||
}
|
||||
for step in seq.steps
|
||||
],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /sequences/{name}/run
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/sequences/{name}/run", summary="Start a sequence", status_code=202)
|
||||
def run_sequence(name: str) -> dict:
|
||||
if ctx.config.sequence(name) is None:
|
||||
raise HTTPException(404, f"Unknown sequence: {name!r}")
|
||||
|
||||
run_id, started = ctx.sequencer.start(name)
|
||||
|
||||
if not started:
|
||||
active = ctx.sequencer.active_run_id()
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Sequence already running (run_id={active!r}). "
|
||||
f"Poll GET /runs/{active} for status.",
|
||||
)
|
||||
|
||||
return {"run_id": run_id, "sequence": name, "status": "running"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /runs/{run_id}
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.get("/runs/{run_id}", summary="Run result by ID")
|
||||
def get_run(run_id: str) -> dict:
|
||||
result = ctx.sequencer.get_result(run_id)
|
||||
if result is None:
|
||||
raise HTTPException(404, f"Unknown run_id: {run_id!r}")
|
||||
return result.to_dict()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /runs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.get("/runs", summary="Recent run history")
|
||||
def list_runs(limit: int = 50) -> list[dict]:
|
||||
return ctx.sequencer.recent_runs(min(limit, 200))
|
||||
|
||||
return app
|
||||
537
arnold/config.py
Normal file
537
arnold/config.py
Normal 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
289
arnold/module_types.py
Normal 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
494
arnold/sequencer.py
Normal 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
663
arnold/terminator_io.py
Normal 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
|
||||
(0–65535), or None on comms error.
|
||||
"""
|
||||
if count == 0:
|
||||
return []
|
||||
with self._lock:
|
||||
return self._fc04_locked(address, count)
|
||||
|
||||
def _fc04_locked(self, address: int, count: int) -> list[int] | None:
|
||||
for attempt in range(2):
|
||||
if not self._connected:
|
||||
if not self._connect_locked():
|
||||
return None
|
||||
try:
|
||||
rr = self._client.read_input_registers(
|
||||
address=address, count=count,
|
||||
device_id=self.device.unit_id,
|
||||
)
|
||||
if rr.isError() or isinstance(rr, ExceptionResponse):
|
||||
log.warning("%s FC04 error: %s", self.device.id, rr)
|
||||
self._connected = False
|
||||
continue
|
||||
return list(rr.registers[:count])
|
||||
except (ModbusException, ConnectionError, OSError) as exc:
|
||||
log.warning("%s FC04 read error (attempt %d): %s",
|
||||
self.device.id, attempt + 1, exc)
|
||||
self._connected = False
|
||||
time.sleep(0.05)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Write digital outputs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def write_output(self, address: int, value: bool) -> bool:
|
||||
"""
|
||||
Write a single coil via FC05.
|
||||
|
||||
Address is the unified slot-order coil address (as stored in
|
||||
LogicalIO.modbus_address). Returns True on success.
|
||||
|
||||
Note: the EBC100 echoes True for any address — write errors for
|
||||
out-of-range addresses are silent. Config validation prevents
|
||||
invalid addresses at startup.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._fc05_locked(address, value)
|
||||
|
||||
def _fc05_locked(self, address: int, value: bool) -> bool:
|
||||
for attempt in range(2):
|
||||
if not self._connected:
|
||||
if not self._connect_locked():
|
||||
return False
|
||||
try:
|
||||
rr = self._client.write_coil(
|
||||
address=address, value=value,
|
||||
device_id=self.device.unit_id,
|
||||
)
|
||||
if rr.isError() or isinstance(rr, ExceptionResponse):
|
||||
log.warning("%s FC05 error addr=%d: %s",
|
||||
self.device.id, address, rr)
|
||||
self._connected = False
|
||||
continue
|
||||
log.debug("%s coil[%d] = %s", self.device.id, address, value)
|
||||
return True
|
||||
except (ModbusException, ConnectionError, OSError) as exc:
|
||||
log.warning("%s write error (attempt %d): %s",
|
||||
self.device.id, attempt + 1, exc)
|
||||
self._connected = False
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
def write_outputs(self, address: int, values: list[bool]) -> bool:
|
||||
"""Write multiple contiguous coils via FC15."""
|
||||
with self._lock:
|
||||
for attempt in range(2):
|
||||
if not self._connected:
|
||||
if not self._connect_locked():
|
||||
return False
|
||||
try:
|
||||
rr = self._client.write_coils(
|
||||
address=address, values=values,
|
||||
device_id=self.device.unit_id,
|
||||
)
|
||||
if rr.isError() or isinstance(rr, ExceptionResponse):
|
||||
log.warning("%s FC15 error addr=%d: %s",
|
||||
self.device.id, address, rr)
|
||||
self._connected = False
|
||||
continue
|
||||
return True
|
||||
except (ModbusException, ConnectionError, OSError) as exc:
|
||||
log.warning("%s write_coils error (attempt %d): %s",
|
||||
self.device.id, attempt + 1, exc)
|
||||
self._connected = False
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Write analog outputs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def write_register(self, address: int, value: int) -> bool:
|
||||
"""
|
||||
Write a single 16-bit holding register via FC06.
|
||||
|
||||
Address is the register-space address (as stored in
|
||||
LogicalIO.modbus_address for analog output signals).
|
||||
value is a raw 16-bit integer (0–65535).
|
||||
"""
|
||||
with self._lock:
|
||||
return self._fc06_locked(address, value)
|
||||
|
||||
def _fc06_locked(self, address: int, value: int) -> bool:
|
||||
for attempt in range(2):
|
||||
if not self._connected:
|
||||
if not self._connect_locked():
|
||||
return False
|
||||
try:
|
||||
rr = self._client.write_register(
|
||||
address=address, value=value,
|
||||
device_id=self.device.unit_id,
|
||||
)
|
||||
if rr.isError() or isinstance(rr, ExceptionResponse):
|
||||
log.warning("%s FC06 error addr=%d: %s",
|
||||
self.device.id, address, rr)
|
||||
self._connected = False
|
||||
continue
|
||||
log.debug("%s reg[%d] = %d", self.device.id, address, value)
|
||||
return True
|
||||
except (ModbusException, ConnectionError, OSError) as exc:
|
||||
log.warning("%s FC06 write error (attempt %d): %s",
|
||||
self.device.id, attempt + 1, exc)
|
||||
self._connected = False
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
def write_registers(self, address: int, values: list[int]) -> bool:
|
||||
"""Write multiple contiguous 16-bit holding registers via FC16."""
|
||||
with self._lock:
|
||||
for attempt in range(2):
|
||||
if not self._connected:
|
||||
if not self._connect_locked():
|
||||
return False
|
||||
try:
|
||||
rr = self._client.write_registers(
|
||||
address=address, values=values,
|
||||
device_id=self.device.unit_id,
|
||||
)
|
||||
if rr.isError() or isinstance(rr, ExceptionResponse):
|
||||
log.warning("%s FC16 error addr=%d: %s",
|
||||
self.device.id, address, rr)
|
||||
self._connected = False
|
||||
continue
|
||||
return True
|
||||
except (ModbusException, ConnectionError, OSError) as exc:
|
||||
log.warning("%s FC16 write error (attempt %d): %s",
|
||||
self.device.id, attempt + 1, exc)
|
||||
self._connected = False
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Status
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"device_id": self.device.id,
|
||||
"host": self.device.host,
|
||||
"port": self.device.port,
|
||||
"connected": self._connected,
|
||||
"connect_attempts": self._connect_attempts,
|
||||
"last_error": self._last_connect_error or None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _PollThread — internal; one per TerminatorIO instance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _PollThread(threading.Thread):
|
||||
"""
|
||||
Reads all input points from one EBC100 at poll_interval_ms, updates the
|
||||
shared signal cache. Daemon thread — exits when the process does.
|
||||
|
||||
Each poll cycle reads BOTH address spaces:
|
||||
- FC02 (coil space): digital input signals → list[bool]
|
||||
- FC04 (register space): analog/temperature input signals → list[int]
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: TerminatorIO,
|
||||
digital_signals: list["LogicalIO"], # digital input signals, sorted by modbus_address
|
||||
analog_signals: list["LogicalIO"], # analog/temp input signals, sorted by modbus_address
|
||||
cache: dict[str, SignalState],
|
||||
lock: threading.Lock,
|
||||
) -> None:
|
||||
super().__init__(name=f"poll-{driver.device.id}", daemon=True)
|
||||
self._driver = driver
|
||||
self._digital_signals = digital_signals
|
||||
self._analog_signals = analog_signals
|
||||
self._cache = cache
|
||||
self._lock = lock
|
||||
|
||||
self._stop = threading.Event()
|
||||
self.poll_count = 0
|
||||
self.error_count = 0
|
||||
self._achieved_hz: float = 0.0
|
||||
self._last_poll_ts: float | None = None
|
||||
|
||||
@property
|
||||
def _total_signals(self) -> int:
|
||||
return len(self._digital_signals) + len(self._analog_signals)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
def run(self) -> None:
|
||||
interval = self._driver.device.poll_interval_ms / 1000.0
|
||||
log.info("Poll thread started: %s %.0f ms interval %d digital + %d analog signals",
|
||||
self._driver.device.id,
|
||||
self._driver.device.poll_interval_ms,
|
||||
len(self._digital_signals),
|
||||
len(self._analog_signals))
|
||||
|
||||
self._driver.connect()
|
||||
|
||||
rate_t0 = time.monotonic()
|
||||
rate_polls = 0
|
||||
|
||||
while not self._stop.is_set():
|
||||
t0 = time.monotonic()
|
||||
self._cycle()
|
||||
|
||||
rate_polls += 1
|
||||
self.poll_count += 1
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
# Update achieved rate every 5 s
|
||||
window = time.monotonic() - rate_t0
|
||||
if window >= 5.0:
|
||||
self._achieved_hz = rate_polls / window
|
||||
log.debug("%s %.1f polls/s errors=%d",
|
||||
self._driver.device.id,
|
||||
self._achieved_hz, self.error_count)
|
||||
rate_t0 = time.monotonic()
|
||||
rate_polls = 0
|
||||
|
||||
wait = interval - elapsed
|
||||
if wait > 0:
|
||||
self._stop.wait(wait)
|
||||
|
||||
log.info("Poll thread stopped: %s", self._driver.device.id)
|
||||
self._driver.disconnect()
|
||||
|
||||
def _cycle(self) -> None:
|
||||
if not self._digital_signals and not self._analog_signals:
|
||||
return
|
||||
|
||||
had_error = False
|
||||
updates: dict[str, SignalState] = {}
|
||||
now = time.monotonic()
|
||||
|
||||
# ── Digital inputs (FC02, coil space) ─────────────────────────
|
||||
if self._digital_signals:
|
||||
bits = self._driver.read_inputs()
|
||||
if bits is None:
|
||||
had_error = True
|
||||
for sig in self._digital_signals:
|
||||
existing = self._cache.get(sig.name)
|
||||
updates[sig.name] = SignalState(
|
||||
name=sig.name,
|
||||
value=existing.value if existing else False,
|
||||
updated_at=existing.updated_at if existing else now,
|
||||
stale=True,
|
||||
)
|
||||
else:
|
||||
for sig in self._digital_signals:
|
||||
if sig.modbus_address < len(bits):
|
||||
updates[sig.name] = SignalState(
|
||||
name=sig.name,
|
||||
value=bool(bits[sig.modbus_address]),
|
||||
updated_at=now,
|
||||
stale=False,
|
||||
)
|
||||
else:
|
||||
log.warning("%s signal %r addr %d out of range (%d bits)",
|
||||
self._driver.device.id, sig.name,
|
||||
sig.modbus_address, len(bits))
|
||||
|
||||
# ── Analog / temperature inputs (FC04, register space) ────────
|
||||
if self._analog_signals:
|
||||
total_regs = self._driver.device.total_analog_input_channels()
|
||||
regs = self._driver.read_registers(address=0, count=total_regs)
|
||||
if regs is None:
|
||||
had_error = True
|
||||
for sig in self._analog_signals:
|
||||
existing = self._cache.get(sig.name)
|
||||
updates[sig.name] = SignalState(
|
||||
name=sig.name,
|
||||
value=existing.value if existing else 0,
|
||||
updated_at=existing.updated_at if existing else now,
|
||||
stale=True,
|
||||
)
|
||||
else:
|
||||
for sig in self._analog_signals:
|
||||
if sig.modbus_address < len(regs):
|
||||
updates[sig.name] = SignalState(
|
||||
name=sig.name,
|
||||
value=int(regs[sig.modbus_address]),
|
||||
updated_at=now,
|
||||
stale=False,
|
||||
)
|
||||
else:
|
||||
log.warning("%s signal %r reg addr %d out of range (%d regs)",
|
||||
self._driver.device.id, sig.name,
|
||||
sig.modbus_address, len(regs))
|
||||
|
||||
if had_error:
|
||||
self.error_count += 1
|
||||
|
||||
self._last_poll_ts = now
|
||||
|
||||
with self._lock:
|
||||
self._cache.update(updates)
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"device_id": self._driver.device.id,
|
||||
"poll_count": self.poll_count,
|
||||
"error_count": self.error_count,
|
||||
"achieved_hz": round(self._achieved_hz, 1),
|
||||
"target_hz": round(1000 / self._driver.device.poll_interval_ms, 1),
|
||||
"last_poll_ts": self._last_poll_ts,
|
||||
"running": self.is_alive(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IORegistry — multi-device coordinator (replaces PollManager + driver dict)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class IORegistry:
|
||||
"""
|
||||
Owns all TerminatorIO drivers and poll threads for the full config.
|
||||
|
||||
Usage:
|
||||
registry = IORegistry(config)
|
||||
registry.start() # connect + begin polling
|
||||
...
|
||||
val = registry.get_value("my_signal")
|
||||
registry.stop()
|
||||
"""
|
||||
|
||||
def __init__(self, config: "Config") -> None:
|
||||
self._config = config
|
||||
self._cache: dict[str, SignalState] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Build one TerminatorIO + one _PollThread per device
|
||||
self._drivers: dict[str, TerminatorIO] = {}
|
||||
self._pollers: list[_PollThread] = []
|
||||
|
||||
for device in config.devices:
|
||||
driver = TerminatorIO(device)
|
||||
self._drivers[device.id] = driver
|
||||
|
||||
# Partition input signals by address space
|
||||
digital_inputs = sorted(
|
||||
(s for s in config.logical_io
|
||||
if s.device == device.id
|
||||
and s.direction == "input"
|
||||
and s.modbus_space == "coil"),
|
||||
key=lambda s: s.modbus_address,
|
||||
)
|
||||
analog_inputs = sorted(
|
||||
(s for s in config.logical_io
|
||||
if s.device == device.id
|
||||
and s.direction == "input"
|
||||
and s.modbus_space == "register"),
|
||||
key=lambda s: s.modbus_address,
|
||||
)
|
||||
poller = _PollThread(
|
||||
driver, digital_inputs, analog_inputs,
|
||||
self._cache, self._lock,
|
||||
)
|
||||
self._pollers.append(poller)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start all poll threads (each connects its own driver on first cycle)."""
|
||||
for p in self._pollers:
|
||||
p.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop all poll threads and disconnect all drivers."""
|
||||
for p in self._pollers:
|
||||
p.stop()
|
||||
for p in self._pollers:
|
||||
p.join(timeout=3)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Signal reads (used by sequencer + API)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get(self, signal_name: str) -> SignalState | None:
|
||||
with self._lock:
|
||||
return self._cache.get(signal_name)
|
||||
|
||||
def get_value(self, signal_name: str) -> bool | int | None:
|
||||
with self._lock:
|
||||
s = self._cache.get(signal_name)
|
||||
return s.value if s is not None else None
|
||||
|
||||
def is_stale(self, signal_name: str) -> bool:
|
||||
with self._lock:
|
||||
s = self._cache.get(signal_name)
|
||||
return s.stale if s is not None else True
|
||||
|
||||
def snapshot(self) -> dict[str, SignalState]:
|
||||
"""Shallow copy of the full signal cache."""
|
||||
with self._lock:
|
||||
return dict(self._cache)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Output writes (used by sequencer)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def driver(self, device_id: str) -> TerminatorIO | None:
|
||||
return self._drivers.get(device_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Status / stats
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def driver_status(self) -> list[dict]:
|
||||
return [d.status() for d in self._drivers.values()]
|
||||
|
||||
def poll_stats(self) -> list[dict]:
|
||||
return [p.stats() for p in self._pollers]
|
||||
329
config.yaml
Normal file
329
config.yaml
Normal 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, 1–8 for 8-pt modules)
|
||||
# direction: must match the module type (input or output)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
logical_io:
|
||||
# ── Slot 1 — Module 1 inputs ───────────────────────────────────────────────
|
||||
- name: input_1_1
|
||||
device: ebc100_main
|
||||
slot: 1
|
||||
point: 1
|
||||
direction: input
|
||||
|
||||
- name: input_1_2
|
||||
device: ebc100_main
|
||||
slot: 1
|
||||
point: 2
|
||||
direction: input
|
||||
|
||||
- name: input_1_3
|
||||
device: ebc100_main
|
||||
slot: 1
|
||||
point: 3
|
||||
direction: input
|
||||
|
||||
- name: input_1_4
|
||||
device: ebc100_main
|
||||
slot: 1
|
||||
point: 4
|
||||
direction: input
|
||||
|
||||
- name: input_1_5
|
||||
device: ebc100_main
|
||||
slot: 1
|
||||
point: 5
|
||||
direction: input
|
||||
|
||||
- name: input_1_6
|
||||
device: ebc100_main
|
||||
slot: 1
|
||||
point: 6
|
||||
direction: input
|
||||
|
||||
- name: input_1_7
|
||||
device: ebc100_main
|
||||
slot: 1
|
||||
point: 7
|
||||
direction: input
|
||||
|
||||
- name: input_1_8
|
||||
device: ebc100_main
|
||||
slot: 1
|
||||
point: 8
|
||||
direction: input
|
||||
|
||||
# ── Slot 2 — Module 2 inputs ───────────────────────────────────────────────
|
||||
- name: input_2_1
|
||||
device: ebc100_main
|
||||
slot: 2
|
||||
point: 1
|
||||
direction: input
|
||||
|
||||
- name: input_2_2
|
||||
device: ebc100_main
|
||||
slot: 2
|
||||
point: 2
|
||||
direction: input
|
||||
|
||||
- name: input_2_3
|
||||
device: ebc100_main
|
||||
slot: 2
|
||||
point: 3
|
||||
direction: input
|
||||
|
||||
- name: input_2_4
|
||||
device: ebc100_main
|
||||
slot: 2
|
||||
point: 4
|
||||
direction: input
|
||||
|
||||
- name: input_2_5
|
||||
device: ebc100_main
|
||||
slot: 2
|
||||
point: 5
|
||||
direction: input
|
||||
|
||||
- name: input_2_6
|
||||
device: ebc100_main
|
||||
slot: 2
|
||||
point: 6
|
||||
direction: input
|
||||
|
||||
- name: input_2_7
|
||||
device: ebc100_main
|
||||
slot: 2
|
||||
point: 7
|
||||
direction: input
|
||||
|
||||
- name: input_2_8
|
||||
device: ebc100_main
|
||||
slot: 2
|
||||
point: 8
|
||||
direction: input
|
||||
|
||||
# ── Slot 3 — Module 3 inputs ───────────────────────────────────────────────
|
||||
- name: input_3_1
|
||||
device: ebc100_main
|
||||
slot: 3
|
||||
point: 1
|
||||
direction: input
|
||||
|
||||
- name: input_3_2
|
||||
device: ebc100_main
|
||||
slot: 3
|
||||
point: 2
|
||||
direction: input
|
||||
|
||||
- name: input_3_3
|
||||
device: ebc100_main
|
||||
slot: 3
|
||||
point: 3
|
||||
direction: input
|
||||
|
||||
- name: input_3_4
|
||||
device: ebc100_main
|
||||
slot: 3
|
||||
point: 4
|
||||
direction: input
|
||||
|
||||
- name: input_3_5
|
||||
device: ebc100_main
|
||||
slot: 3
|
||||
point: 5
|
||||
direction: input
|
||||
|
||||
- name: input_3_6
|
||||
device: ebc100_main
|
||||
slot: 3
|
||||
point: 6
|
||||
direction: input
|
||||
|
||||
- name: input_3_7
|
||||
device: ebc100_main
|
||||
slot: 3
|
||||
point: 7
|
||||
direction: input
|
||||
|
||||
- name: input_3_8
|
||||
device: ebc100_main
|
||||
slot: 3
|
||||
point: 8
|
||||
direction: input
|
||||
|
||||
# ── Outputs (uncomment when output module is added) ────────────────────────
|
||||
# - name: output_4_1
|
||||
# device: ebc100_main
|
||||
# slot: 4
|
||||
# point: 1
|
||||
# direction: output
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# SEQUENCES
|
||||
# name: unique identifier (used in POST /sequences/{name}/run)
|
||||
# description: human-readable label
|
||||
# steps: list of timed actions, executed in t_ms order
|
||||
#
|
||||
# Step fields:
|
||||
# t_ms: milliseconds from sequence T=0 when this step fires (absolute)
|
||||
# action: set_output | check_input
|
||||
# signal: logical_io name
|
||||
# state: (set_output only) true=ON false=OFF
|
||||
# expected: (check_input only) true=ON false=OFF — failure aborts sequence
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
sequences:
|
||||
# ── Example: verify all inputs on module 1 are OFF at rest ────────────────
|
||||
- name: check_all_inputs_off
|
||||
description: "Verify all 24 inputs are de-energised (rest state check)"
|
||||
steps:
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_1_1
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_1_2
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_1_3
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_1_4
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_1_5
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_1_6
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_1_7
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_1_8
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_2_1
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_2_2
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_2_3
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_2_4
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_2_5
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_2_6
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_2_7
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_2_8
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_3_1
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_3_2
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_3_3
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_3_4
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_3_5
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_3_6
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_3_7
|
||||
expected: false
|
||||
- t_ms: 0
|
||||
action: check_input
|
||||
signal: input_3_8
|
||||
expected: false
|
||||
|
||||
# ── Example: output sequencing template (requires output module in slot 4) ─
|
||||
# - name: actuate_and_verify
|
||||
# description: "Set output, wait 500ms, verify input feedback"
|
||||
# steps:
|
||||
# - t_ms: 0
|
||||
# action: set_output
|
||||
# signal: output_4_1
|
||||
# state: true
|
||||
# - t_ms: 500
|
||||
# action: check_input
|
||||
# signal: input_1_1
|
||||
# expected: true
|
||||
# - t_ms: 1000
|
||||
# action: set_output
|
||||
# signal: output_4_1
|
||||
# state: false
|
||||
149
config_with_outputs.yaml
Normal file
149
config_with_outputs.yaml
Normal 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 1–16 in order. Total duration: 32 seconds.
|
||||
steps:
|
||||
# Output 1
|
||||
- { t_ms: 0, action: set_output, signal: output_3_1, state: true }
|
||||
- { t_ms: 1000, action: set_output, signal: output_3_1, state: false }
|
||||
# Output 2
|
||||
- { t_ms: 2000, action: set_output, signal: output_3_2, state: true }
|
||||
- { t_ms: 3000, action: set_output, signal: output_3_2, state: false }
|
||||
# Output 3
|
||||
- { t_ms: 4000, action: set_output, signal: output_3_3, state: true }
|
||||
- { t_ms: 5000, action: set_output, signal: output_3_3, state: false }
|
||||
# Output 4
|
||||
- { t_ms: 6000, action: set_output, signal: output_3_4, state: true }
|
||||
- { t_ms: 7000, action: set_output, signal: output_3_4, state: false }
|
||||
# Output 5
|
||||
- { t_ms: 8000, action: set_output, signal: output_3_5, state: true }
|
||||
- { t_ms: 9000, action: set_output, signal: output_3_5, state: false }
|
||||
# Output 6
|
||||
- { t_ms: 10000, action: set_output, signal: output_3_6, state: true }
|
||||
- { t_ms: 11000, action: set_output, signal: output_3_6, state: false }
|
||||
# Output 7
|
||||
- { t_ms: 12000, action: set_output, signal: output_3_7, state: true }
|
||||
- { t_ms: 13000, action: set_output, signal: output_3_7, state: false }
|
||||
# Output 8
|
||||
- { t_ms: 14000, action: set_output, signal: output_3_8, state: true }
|
||||
- { t_ms: 15000, action: set_output, signal: output_3_8, state: false }
|
||||
# Output 9
|
||||
- { t_ms: 16000, action: set_output, signal: output_3_9, state: true }
|
||||
- { t_ms: 17000, action: set_output, signal: output_3_9, state: false }
|
||||
# Output 10
|
||||
- { t_ms: 18000, action: set_output, signal: output_3_10, state: true }
|
||||
- { t_ms: 19000, action: set_output, signal: output_3_10, state: false }
|
||||
# Output 11
|
||||
- { t_ms: 20000, action: set_output, signal: output_3_11, state: true }
|
||||
- { t_ms: 21000, action: set_output, signal: output_3_11, state: false }
|
||||
# Output 12
|
||||
- { t_ms: 22000, action: set_output, signal: output_3_12, state: true }
|
||||
- { t_ms: 23000, action: set_output, signal: output_3_12, state: false }
|
||||
# Output 13
|
||||
- { t_ms: 24000, action: set_output, signal: output_3_13, state: true }
|
||||
- { t_ms: 25000, action: set_output, signal: output_3_13, state: false }
|
||||
# Output 14
|
||||
- { t_ms: 26000, action: set_output, signal: output_3_14, state: true }
|
||||
- { t_ms: 27000, action: set_output, signal: output_3_14, state: false }
|
||||
# Output 15
|
||||
- { t_ms: 28000, action: set_output, signal: output_3_15, state: true }
|
||||
- { t_ms: 29000, action: set_output, signal: output_3_15, state: false }
|
||||
# Output 16
|
||||
- { t_ms: 30000, action: set_output, signal: output_3_16, state: true }
|
||||
- { t_ms: 31000, action: set_output, signal: output_3_16, state: false }
|
||||
|
||||
# ── Safety reset — drive all outputs OFF ──────────────────────────────────
|
||||
- name: all_outputs_off
|
||||
description: "Force all 16 outputs OFF immediately (safety reset)"
|
||||
steps:
|
||||
- { t_ms: 0, action: set_output, signal: output_3_1, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_2, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_3, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_4, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_5, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_6, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_7, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_8, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_9, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_10, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_11, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_12, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_13, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_14, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_15, state: false }
|
||||
- { t_ms: 0, action: set_output, signal: output_3_16, state: false }
|
||||
281
probe_terminator.py
Normal file
281
probe_terminator.py
Normal 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
12
runs.log
Normal 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
156
server.py
Normal 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
883
tui.py
Normal 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
600
web/app.js
Normal 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">−</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 · ${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
71
web/index.html
Normal 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 — 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 — Terminator I/O Server
|
||||
</footer>
|
||||
|
||||
<script src="/web/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
463
web/style.css
Normal file
463
web/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user