first
This commit is contained in:
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]
|
||||
Reference in New Issue
Block a user