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

2
arnold/__init__.py Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

263
arnold/api.py Normal file
View File

@@ -0,0 +1,263 @@
"""
arnold/api.py — FastAPI REST application.
Receives an AppContext at creation time; no module-level mutable globals.
Endpoints:
GET / Redirect to web UI
GET /web/* Static files (web interface)
GET /status Device comms health + poll stats
GET /io All signal states from the poll cache
GET /io/{signal} Single signal state + metadata
POST /io/{signal}/write Write an output signal value
GET /config/signals Full signal metadata for UI bootstrap
GET /sequences List sequences defined in config
GET /sequences/{name} Sequence detail with step list
POST /sequences/{name}/run Start a sequence → {run_id} (409 if busy)
GET /runs Recent run log (last 50, most recent first)
GET /runs/{run_id} Single run result (pending/running/success/failed/error)
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from .config import Config
from .sequencer import Sequencer
from .terminator_io import IORegistry
@dataclass
class AppContext:
"""All runtime objects the API needs, passed in at app creation."""
config: Config
registry: IORegistry
sequencer: Sequencer
started_at: float
def create_app(ctx: AppContext) -> FastAPI:
app = FastAPI(
title="Arnold — Terminator I/O Server",
version="0.1.0",
description=(
"Fast-poll Modbus TCP I/O server for AutomationDirect Terminator I/O. "
"Provides real-time signal state and timed sequence execution."
),
)
# ------------------------------------------------------------------
# Web UI — static files + root redirect
# ------------------------------------------------------------------
# Locate the web/ directory relative to this file
_web_dir = Path(__file__).resolve().parent.parent / "web"
if _web_dir.is_dir():
app.mount("/web", StaticFiles(directory=str(_web_dir)), name="web-static")
@app.get("/", include_in_schema=False)
def root_redirect():
return RedirectResponse(url="/web/index.html")
# ------------------------------------------------------------------
# GET /status
# ------------------------------------------------------------------
@app.get("/status", summary="Device comms health and poll statistics")
def get_status() -> dict:
return {
"status": "ok",
"uptime_s": round(time.monotonic() - ctx.started_at, 1),
"devices": ctx.registry.driver_status(),
"poll_stats": ctx.registry.poll_stats(),
"active_run": ctx.sequencer.active_run_id(),
}
# ------------------------------------------------------------------
# GET /io
# ------------------------------------------------------------------
@app.get("/io", summary="All signal states")
def get_all_io() -> dict[str, Any]:
return {
name: {
"value": s.value,
"stale": s.stale,
"updated_at": s.updated_at,
}
for name, s in ctx.registry.snapshot().items()
}
# ------------------------------------------------------------------
# GET /io/{signal}
# ------------------------------------------------------------------
@app.get("/io/{signal_name}", summary="Single signal state")
def get_signal(signal_name: str) -> dict:
sig = ctx.config.signal(signal_name)
if sig is None:
raise HTTPException(404, f"Unknown signal: {signal_name!r}")
s = ctx.registry.get(signal_name)
return {
"name": signal_name,
"direction": sig.direction,
"category": sig.category,
"value_type": sig.value_type,
"modbus_space": sig.modbus_space,
"device": sig.device,
"slot": sig.slot,
"point": sig.point,
"modbus_address": sig.modbus_address,
"value": s.value if s else None,
"stale": s.stale if s else True,
"updated_at": s.updated_at if s else None,
}
# ------------------------------------------------------------------
# POST /io/{signal}/write — write an output value
# ------------------------------------------------------------------
class WriteRequest(BaseModel):
value: bool | int # bool for digital, int for analog
@app.post("/io/{signal_name}/write", summary="Write an output signal")
def write_signal(signal_name: str, req: WriteRequest) -> dict:
sig = ctx.config.signal(signal_name)
if sig is None:
raise HTTPException(404, f"Unknown signal: {signal_name!r}")
if sig.direction != "output":
raise HTTPException(400, f"Signal {signal_name!r} is an input, not writable")
driver = ctx.registry.driver(sig.device)
if driver is None:
raise HTTPException(503, f"No driver for device {sig.device!r}")
if sig.value_type == "int":
val = int(req.value)
if val < 0 or val > 65535:
raise HTTPException(400, f"Analog value must be 065535, got {val}")
ok = driver.write_register(sig.modbus_address, val)
else:
val = bool(req.value)
ok = driver.write_output(sig.modbus_address, val)
if not ok:
raise HTTPException(502, f"Write failed for {signal_name!r}")
return {"signal": signal_name, "value": val, "ok": True}
# ------------------------------------------------------------------
# GET /config/signals — full signal metadata for UI bootstrap
# ------------------------------------------------------------------
@app.get("/config/signals", summary="All signal metadata from config")
def get_config_signals() -> list[dict]:
return [
{
"name": s.name,
"direction": s.direction,
"category": s.category,
"value_type": s.value_type,
"modbus_space": s.modbus_space,
"device": s.device,
"slot": s.slot,
"point": s.point,
"modbus_address": s.modbus_address,
"default_state": s.default_state,
"default_value": s.default_value,
}
for s in ctx.config.logical_io
]
# ------------------------------------------------------------------
# GET /sequences
# ------------------------------------------------------------------
@app.get("/sequences", summary="List all sequences")
def list_sequences() -> list[dict]:
return [
{"name": seq.name, "description": seq.description, "steps": len(seq.steps)}
for seq in ctx.config.sequences
]
# ------------------------------------------------------------------
# GET /sequences/{name}
# ------------------------------------------------------------------
@app.get("/sequences/{name}", summary="Sequence detail")
def get_sequence(name: str) -> dict:
seq = ctx.config.sequence(name)
if seq is None:
raise HTTPException(404, f"Unknown sequence: {name!r}")
return {
"name": seq.name,
"description": seq.description,
"steps": [
{
"t_ms": step.t_ms,
"action": step.action,
"signal": step.signal,
# Digital fields (None for analog)
"state": step.state,
"expected": step.expected,
# Analog fields (None for digital)
"value": step.value,
"expected_value": step.expected_value,
"tolerance": step.tolerance,
# wait_input
"timeout_ms": step.timeout_ms,
}
for step in seq.steps
],
}
# ------------------------------------------------------------------
# POST /sequences/{name}/run
# ------------------------------------------------------------------
@app.post("/sequences/{name}/run", summary="Start a sequence", status_code=202)
def run_sequence(name: str) -> dict:
if ctx.config.sequence(name) is None:
raise HTTPException(404, f"Unknown sequence: {name!r}")
run_id, started = ctx.sequencer.start(name)
if not started:
active = ctx.sequencer.active_run_id()
raise HTTPException(
409,
f"Sequence already running (run_id={active!r}). "
f"Poll GET /runs/{active} for status.",
)
return {"run_id": run_id, "sequence": name, "status": "running"}
# ------------------------------------------------------------------
# GET /runs/{run_id}
# ------------------------------------------------------------------
@app.get("/runs/{run_id}", summary="Run result by ID")
def get_run(run_id: str) -> dict:
result = ctx.sequencer.get_result(run_id)
if result is None:
raise HTTPException(404, f"Unknown run_id: {run_id!r}")
return result.to_dict()
# ------------------------------------------------------------------
# GET /runs
# ------------------------------------------------------------------
@app.get("/runs", summary="Recent run history")
def list_runs(limit: int = 50) -> list[dict]:
return ctx.sequencer.recent_runs(min(limit, 200))
return app

537
arnold/config.py Normal file
View File

@@ -0,0 +1,537 @@
"""
arnold/config.py — YAML config loader, dataclasses, and validation.
Schema:
devices: list of EBC100 controllers
logical_io: named signals mapped to device/slot/point
sequences: ordered step lists with timing and actions
Address spaces (EBC100 hardware):
The T1H-EBC100 maintains TWO independent Modbus address spaces:
coil space (1-bit) — digital modules (inputs + outputs + relays)
FC01 read coils, FC02 read discrete inputs,
FC05 write single coil, FC15 write multiple coils
Flat sequential addressing by physical slot order.
register space (16-bit) — analog + temperature modules
FC03 read holding registers, FC04 read input registers,
FC06 write single register, FC16 write multiple registers
Flat sequential addressing by physical slot order,
INDEPENDENT of coil space.
A digital module in slot 1 advances coil_offset but not register_offset.
An analog module in slot 2 advances register_offset but not coil_offset.
"""
from __future__ import annotations
import yaml
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from .module_types import MODULE_REGISTRY, ModuleType, Category, get_module_type
# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------
@dataclass
class ModuleConfig:
slot: int # 1-based physical slot number
type: str # part number, e.g. "T1H-08TDS"
module_type: ModuleType # resolved from registry
points: int # number of I/O points/channels
category: Category # from ModuleType
modbus_space: Literal["coil", "register"] # from ModuleType
# Modbus base address (0-based) within the appropriate space,
# computed at load time.
modbus_address: int = 0
@dataclass
class DeviceConfig:
id: str
host: str
port: int
unit_id: int
poll_interval_ms: int
modules: list[ModuleConfig] = field(default_factory=list)
def module_for_slot(self, slot: int) -> ModuleConfig | None:
return next((m for m in self.modules if m.slot == slot), None)
# -- Digital helpers ---------------------------------------------------
def digital_input_modules(self) -> list[ModuleConfig]:
return [m for m in self.modules
if m.category == "digital_input"]
def digital_output_modules(self) -> list[ModuleConfig]:
return [m for m in self.modules
if m.category in ("digital_output", "relay_output")]
def total_input_points(self) -> int:
"""Total discrete input bits (for FC02 bulk read)."""
return sum(m.points for m in self.digital_input_modules())
def total_output_points(self) -> int:
"""Total discrete output bits."""
return sum(m.points for m in self.digital_output_modules())
# -- Analog helpers ----------------------------------------------------
def analog_input_modules(self) -> list[ModuleConfig]:
return [m for m in self.modules
if m.category in ("analog_input", "temperature_input")]
def analog_output_modules(self) -> list[ModuleConfig]:
return [m for m in self.modules
if m.category == "analog_output"]
def total_analog_input_channels(self) -> int:
"""Total 16-bit input registers (for FC04 bulk read)."""
return sum(m.points for m in self.analog_input_modules())
def total_analog_output_channels(self) -> int:
"""Total 16-bit output registers."""
return sum(m.points for m in self.analog_output_modules())
# -- Generic helpers ---------------------------------------------------
def input_modules(self) -> list[ModuleConfig]:
"""All modules that are inputs (digital + analog + temperature)."""
return [m for m in self.modules
if m.module_type.direction == "input"]
def output_modules(self) -> list[ModuleConfig]:
"""All modules that are outputs (digital + relay + analog)."""
return [m for m in self.modules
if m.module_type.direction == "output"]
@dataclass
class LogicalIO:
name: str
device: str # device id
slot: int # 1-based
point: int # 1-based within slot
direction: Literal["input", "output"]
category: Category # full category from ModuleType
modbus_space: Literal["coil", "register"] # which address space
value_type: Literal["bool", "int"] # bool for digital, int for analog
# For digital outputs: optional startup default
default_state: bool | None = None
# For analog outputs: optional startup default (raw register value)
default_value: int | None = None
# Resolved at load time:
modbus_address: int = 0 # 0-based within the appropriate space
device_ref: DeviceConfig | None = None
module_ref: ModuleConfig | None = None
@dataclass
class SequenceStep:
t_ms: int # absolute ms from sequence T=0
action: Literal["set_output", "check_input", "wait_input"]
signal: str # logical_io name
# For digital set_output:
state: bool | None = None
# For analog set_output (raw register value):
value: int | None = None
# For check_input / wait_input (digital):
expected: bool | None = None
# For check_input / wait_input (analog — raw register value):
expected_value: int | None = None
tolerance: int | None = None # analog: abs(actual - expected) <= tolerance
# For wait_input only:
timeout_ms: int | None = None
@dataclass
class Sequence:
name: str
description: str
steps: list[SequenceStep] = field(default_factory=list)
@dataclass
class Config:
devices: list[DeviceConfig]
logical_io: list[LogicalIO]
sequences: list[Sequence]
def device(self, device_id: str) -> DeviceConfig | None:
return next((d for d in self.devices if d.id == device_id), None)
def signal(self, name: str) -> LogicalIO | None:
return next((s for s in self.logical_io if s.name == name), None)
def sequence(self, name: str) -> Sequence | None:
return next((s for s in self.sequences if s.name == name), None)
# ---------------------------------------------------------------------------
# Loader
# ---------------------------------------------------------------------------
class ConfigError(Exception):
pass
def _parse_bool(val: object, context: str) -> bool:
if isinstance(val, bool):
return val
if isinstance(val, str):
if val.lower() in ("true", "yes", "on", "1"):
return True
if val.lower() in ("false", "no", "off", "0"):
return False
raise ConfigError(f"{context}: expected boolean, got {val!r}")
def load(path: str | Path) -> Config:
"""Load and validate a YAML config file. Raises ConfigError on any problem."""
path = Path(path)
if not path.exists():
raise ConfigError(f"Config file not found: {path}")
with open(path) as f:
raw = yaml.safe_load(f)
if not isinstance(raw, dict):
raise ConfigError("Config file must be a YAML mapping at the top level")
devices = _parse_devices(raw.get("devices") or [])
device_map = {d.id: d for d in devices}
logical_io = _parse_logical_io(raw.get("logical_io") or [], device_map)
signal_map = {s.name: s for s in logical_io}
sequences = _parse_sequences(raw.get("sequences") or [], signal_map)
return Config(devices=devices, logical_io=logical_io, sequences=sequences)
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
def _parse_devices(raw_list: list) -> list[DeviceConfig]:
if not isinstance(raw_list, list):
raise ConfigError("'devices' must be a list")
devices: list[DeviceConfig] = []
seen_ids: set[str] = set()
for i, raw in enumerate(raw_list):
ctx = f"devices[{i}]"
if not isinstance(raw, dict):
raise ConfigError(f"{ctx}: must be a mapping")
dev_id = _require_str(raw, "id", ctx)
if dev_id in seen_ids:
raise ConfigError(f"{ctx}: duplicate device id {dev_id!r}")
seen_ids.add(dev_id)
host = _require_str(raw, "host", ctx)
port = int(raw.get("port", 502))
unit_id = int(raw.get("unit_id", 1))
poll_interval = int(raw.get("poll_interval_ms", 50))
modules = _parse_modules(raw.get("modules") or [], ctx)
# Assign Modbus addresses using TWO independent counters.
#
# The T1H-EBC100 has separate address spaces for coils (digital)
# and registers (analog/temperature). Each space is flat and
# sequential by physical slot order. A digital module advances
# only the coil counter; an analog module advances only the
# register counter.
coil_offset = 0
register_offset = 0
for m in sorted(modules, key=lambda m: m.slot):
if m.modbus_space == "coil":
m.modbus_address = coil_offset
coil_offset += m.points
else:
m.modbus_address = register_offset
register_offset += m.points
devices.append(DeviceConfig(
id=dev_id,
host=host,
port=port,
unit_id=unit_id,
poll_interval_ms=poll_interval,
modules=sorted(modules, key=lambda m: m.slot),
))
return devices
def _parse_modules(raw_list: list, parent_ctx: str) -> list[ModuleConfig]:
if not isinstance(raw_list, list):
raise ConfigError(f"{parent_ctx}.modules: must be a list")
modules: list[ModuleConfig] = []
seen_slots: set[int] = set()
for i, raw in enumerate(raw_list):
ctx = f"{parent_ctx}.modules[{i}]"
if not isinstance(raw, dict):
raise ConfigError(f"{ctx}: must be a mapping")
slot = int(_require(raw, "slot", ctx))
mod_type = _require_str(raw, "type", ctx)
if slot < 1:
raise ConfigError(f"{ctx}: slot must be >= 1, got {slot}")
if slot in seen_slots:
raise ConfigError(f"{ctx}: duplicate slot {slot}")
seen_slots.add(slot)
try:
mt = get_module_type(mod_type)
except KeyError as exc:
raise ConfigError(f"{ctx}: {exc}") from None
points = int(raw.get("points", mt.points))
modules.append(ModuleConfig(
slot=slot,
type=mod_type,
module_type=mt,
points=points,
category=mt.category,
modbus_space=mt.modbus_space,
))
return modules
def _parse_logical_io(
raw_list: list, device_map: dict[str, DeviceConfig]
) -> list[LogicalIO]:
if not isinstance(raw_list, list):
raise ConfigError("'logical_io' must be a list")
signals: list[LogicalIO] = []
seen_names: set[str] = set()
for i, raw in enumerate(raw_list):
ctx = f"logical_io[{i}]"
if not isinstance(raw, dict):
raise ConfigError(f"{ctx}: must be a mapping")
name = _require_str(raw, "name", ctx)
dev_id = _require_str(raw, "device", ctx)
slot = int(_require(raw, "slot", ctx))
point = int(_require(raw, "point", ctx))
direction = _require_str(raw, "direction", ctx)
if name in seen_names:
raise ConfigError(f"{ctx}: duplicate signal name {name!r}")
seen_names.add(name)
if direction not in ("input", "output"):
raise ConfigError(
f"{ctx}: direction must be 'input' or 'output', got {direction!r}"
)
dev = device_map.get(dev_id)
if dev is None:
raise ConfigError(f"{ctx}: references unknown device {dev_id!r}")
mod = dev.module_for_slot(slot)
if mod is None:
slots = [m.slot for m in dev.modules]
raise ConfigError(
f"{ctx}: device {dev_id!r} has no module at slot {slot}. "
f"Available slots: {slots}"
)
if mod.module_type.direction != direction:
raise ConfigError(
f"{ctx}: signal direction {direction!r} does not match "
f"module type {mod.type!r} which is {mod.module_type.direction!r}"
)
if point < 1 or point > mod.points:
raise ConfigError(
f"{ctx}: point {point} out of range for module at slot {slot} "
f"(module has {mod.points} points, 1-based)"
)
# Parse default_state (digital outputs only)
default_state: bool | None = None
if "default_state" in raw:
if not mod.module_type.is_digital or direction != "output":
raise ConfigError(
f"{ctx}: default_state is only valid for digital output signals"
)
default_state = _parse_bool(raw["default_state"], ctx + ".default_state")
# Parse default_value (analog outputs only)
default_value: int | None = None
if "default_value" in raw:
if not mod.module_type.is_analog or direction != "output":
raise ConfigError(
f"{ctx}: default_value is only valid for analog output signals"
)
default_value = int(raw["default_value"])
# Modbus address = module's base address + (point - 1)
modbus_address = mod.modbus_address + (point - 1)
signals.append(LogicalIO(
name=name,
device=dev_id,
slot=slot,
point=point,
direction=direction,
category=mod.category,
modbus_space=mod.modbus_space,
value_type=mod.module_type.value_type,
default_state=default_state,
default_value=default_value,
modbus_address=modbus_address,
device_ref=dev,
module_ref=mod,
))
return signals
def _parse_sequences(
raw_list: list, signal_map: dict[str, LogicalIO]
) -> list[Sequence]:
if not isinstance(raw_list, list):
raise ConfigError("'sequences' must be a list")
sequences: list[Sequence] = []
seen_names: set[str] = set()
for i, raw in enumerate(raw_list):
ctx = f"sequences[{i}]"
if not isinstance(raw, dict):
raise ConfigError(f"{ctx}: must be a mapping")
name = _require_str(raw, "name", ctx)
description = raw.get("description", "")
if name in seen_names:
raise ConfigError(f"{ctx}: duplicate sequence name {name!r}")
seen_names.add(name)
steps = _parse_steps(raw.get("steps") or [], signal_map, ctx)
sequences.append(Sequence(name=name, description=description, steps=steps))
return sequences
def _parse_steps(
raw_list: list, signal_map: dict[str, LogicalIO], parent_ctx: str
) -> list[SequenceStep]:
if not isinstance(raw_list, list):
raise ConfigError(f"{parent_ctx}.steps: must be a list")
steps: list[SequenceStep] = []
for i, raw in enumerate(raw_list):
ctx = f"{parent_ctx}.steps[{i}]"
if not isinstance(raw, dict):
raise ConfigError(f"{ctx}: must be a mapping")
t_ms = int(_require(raw, "t_ms", ctx))
action = _require_str(raw, "action", ctx)
signal = _require_str(raw, "signal", ctx)
if t_ms < 0:
raise ConfigError(f"{ctx}: t_ms must be >= 0, got {t_ms}")
if action not in ("set_output", "check_input", "wait_input"):
raise ConfigError(
f"{ctx}: action must be 'set_output', 'check_input', or 'wait_input',"
f" got {action!r}"
)
sig = signal_map.get(signal)
if sig is None:
raise ConfigError(f"{ctx}: references unknown signal {signal!r}")
step = SequenceStep(t_ms=t_ms, action=action, signal=signal)
if action == "set_output":
if sig.direction != "output":
raise ConfigError(
f"{ctx}: set_output action used on signal {signal!r} "
f"which is an input — use check_input instead"
)
if sig.value_type == "bool":
if "state" not in raw:
raise ConfigError(
f"{ctx}: set_output step for digital signal requires 'state'"
)
step.state = _parse_bool(raw["state"], ctx + ".state")
else:
if "value" not in raw:
raise ConfigError(
f"{ctx}: set_output step for analog signal requires 'value'"
)
step.value = int(raw["value"])
elif action in ("check_input", "wait_input"):
if sig.value_type == "bool":
if "expected" not in raw:
raise ConfigError(
f"{ctx}: {action} step for digital signal requires 'expected'"
)
step.expected = _parse_bool(raw["expected"], ctx + ".expected")
else:
if "expected_value" not in raw:
raise ConfigError(
f"{ctx}: {action} step for analog signal requires "
f"'expected_value'"
)
step.expected_value = int(raw["expected_value"])
if "tolerance" in raw:
step.tolerance = int(raw["tolerance"])
else:
step.tolerance = 0
if action == "wait_input":
if "timeout_ms" not in raw:
raise ConfigError(
f"{ctx}: wait_input step requires 'timeout_ms' field"
)
step.timeout_ms = int(raw["timeout_ms"])
if step.timeout_ms <= 0:
raise ConfigError(
f"{ctx}: timeout_ms must be > 0, got {step.timeout_ms}"
)
steps.append(step)
# Sort by t_ms so steps need not be in order in the YAML
steps.sort(key=lambda s: s.t_ms)
return steps
# ---------------------------------------------------------------------------
# Tiny helpers
# ---------------------------------------------------------------------------
def _require(d: dict, key: str, ctx: str) -> object:
if key not in d:
raise ConfigError(f"{ctx}: missing required field '{key}'")
return d[key]
def _require_str(d: dict, key: str, ctx: str) -> str:
val = _require(d, key, ctx)
if not isinstance(val, str):
raise ConfigError(f"{ctx}.{key}: expected string, got {type(val).__name__}")
return val

289
arnold/module_types.py Normal file
View File

@@ -0,0 +1,289 @@
"""
arnold/module_types.py — Terminator I/O module type registry.
Every T1H (full-size) and T1K (compact) module that the EBC100 supports
is catalogued here as a frozen ModuleType dataclass.
Categories
----------
digital_input FC02 (read discrete inputs) — coil address space
digital_output FC01/FC05/FC15 (read/write coils) — coil address space
relay_output Same Modbus behaviour as digital_output, relay contacts
analog_input FC04 (read input registers) — register address space
analog_output FC03/FC06/FC16 (read/write holding registers) — register space
temperature_input FC04 (read input registers) — register address space
Address spaces
--------------
The EBC100 maintains TWO independent flat address spaces:
coil space — digital modules only, 1-bit per point
register space — analog + temperature modules, 16-bit per channel
Digital and analog modules do NOT interfere with each other's address
offsets. A digital-input module in slot 1 advances coil_offset but
has zero effect on register_offset, and vice-versa.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
# ---------------------------------------------------------------------------
# Category type
# ---------------------------------------------------------------------------
Category = Literal[
"digital_input",
"digital_output",
"relay_output",
"analog_input",
"analog_output",
"temperature_input",
]
# ---------------------------------------------------------------------------
# ModuleType dataclass
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ModuleType:
part_number: str # e.g. "T1H-08TDS"
series: Literal["T1H", "T1K"] # housing form factor
category: Category
points: int # I/O count (channels for analog)
signal_type: str # human description of signal
resolution_bits: int | None = None # None for digital, 12/15/16 for analog
range_options: tuple[str, ...] = () # e.g. ("0-20mA","4-20mA","0-10V","±10V")
max_current_per_point: str = "" # e.g. "0.3A", "1A@250VAC"
# -- Derived properties --------------------------------------------------
@property
def is_digital(self) -> bool:
return self.category in (
"digital_input", "digital_output", "relay_output",
)
@property
def is_analog(self) -> bool:
return self.category in (
"analog_input", "analog_output", "temperature_input",
)
@property
def direction(self) -> Literal["input", "output"]:
if self.category in ("digital_input", "analog_input", "temperature_input"):
return "input"
return "output"
@property
def modbus_space(self) -> Literal["coil", "register"]:
"""Which EBC100 address space this module lives in."""
return "coil" if self.is_digital else "register"
@property
def value_type(self) -> Literal["bool", "int"]:
"""Python type of per-point values: bool for digital, int for analog."""
return "bool" if self.is_digital else "int"
# ---------------------------------------------------------------------------
# Full registry
# ---------------------------------------------------------------------------
_ALL_MODULES: list[ModuleType] = [
# ══════════════════════════════════════════════════════════════════════════
# DIGITAL INPUTS — T1H
# ══════════════════════════════════════════════════════════════════════════
ModuleType("T1H-08TDS", "T1H", "digital_input", 8,
"24VDC sinking (NPN) input, 4.1mA/pt"),
ModuleType("T1H-08ND3", "T1H", "digital_input", 8,
"24VDC sinking/sourcing input"),
ModuleType("T1H-08ND3P", "T1H", "digital_input", 8,
"24VDC sourcing (PNP) input"),
ModuleType("T1H-16ND3", "T1H", "digital_input", 16,
"24VDC sinking/sourcing input"),
ModuleType("T1H-08NA", "T1H", "digital_input", 8,
"120VAC input"),
ModuleType("T1H-16NA", "T1H", "digital_input", 16,
"120VAC input"),
# DIGITAL INPUTS — T1K
ModuleType("T1K-08TDS", "T1K", "digital_input", 8,
"24VDC sinking (NPN) input, 4.1mA/pt"),
ModuleType("T1K-08ND3", "T1K", "digital_input", 8,
"24VDC sinking/sourcing input"),
ModuleType("T1K-16ND3", "T1K", "digital_input", 16,
"24VDC sinking/sourcing input"),
# ══════════════════════════════════════════════════════════════════════════
# DIGITAL OUTPUTS — T1H
# ══════════════════════════════════════════════════════════════════════════
ModuleType("T1H-08TD1", "T1H", "digital_output", 8,
"24VDC sourcing transistor output",
max_current_per_point="0.3A"),
ModuleType("T1H-08TD2", "T1H", "digital_output", 8,
"12-24VDC sinking transistor output",
max_current_per_point="0.3A"),
ModuleType("T1H-16TD1", "T1H", "digital_output", 16,
"24VDC sourcing transistor output",
max_current_per_point="0.1A"),
ModuleType("T1H-16TD2", "T1H", "digital_output", 16,
"12-24VDC sinking transistor output",
max_current_per_point="0.1A"),
ModuleType("T1H-08TA", "T1H", "digital_output", 8,
"120/240VAC triac output",
max_current_per_point="0.5A"),
# DIGITAL OUTPUTS — T1K
ModuleType("T1K-08TD1", "T1K", "digital_output", 8,
"24VDC sourcing transistor output",
max_current_per_point="0.3A"),
ModuleType("T1K-08TD2", "T1K", "digital_output", 8,
"12-24VDC sinking transistor output",
max_current_per_point="0.3A"),
ModuleType("T1K-16TD1", "T1K", "digital_output", 16,
"24VDC sourcing transistor output",
max_current_per_point="0.1A"),
ModuleType("T1K-16TD2", "T1K", "digital_output", 16,
"12-24VDC sinking transistor output",
max_current_per_point="0.1A"),
ModuleType("T1K-16TD2-1","T1K", "digital_output", 16,
"12-24VDC sourcing transistor output",
max_current_per_point="0.1A"),
ModuleType("T1K-08TA", "T1K", "digital_output", 8,
"120/240VAC triac output",
max_current_per_point="0.5A"),
# ══════════════════════════════════════════════════════════════════════════
# RELAY OUTPUTS
# ══════════════════════════════════════════════════════════════════════════
ModuleType("T1H-08TRS", "T1H", "relay_output", 8,
"Relay output (Form A SPST-NO)",
max_current_per_point="1A@250VAC"),
ModuleType("T1H-16TRS", "T1H", "relay_output", 16,
"Relay output (Form A SPST-NO)",
max_current_per_point="1A@250VAC"),
ModuleType("T1H-16TRS2", "T1H", "relay_output", 16,
"Relay output (Form A SPST-NO)",
max_current_per_point="2A@250VAC"),
ModuleType("T1K-08TRS", "T1K", "relay_output", 8,
"Relay output (Form A SPST-NO)",
max_current_per_point="1A@250VAC"),
# ══════════════════════════════════════════════════════════════════════════
# ANALOG INPUTS — T1H
# ══════════════════════════════════════════════════════════════════════════
ModuleType("T1H-08AD-1", "T1H", "analog_input", 8,
"Voltage/current analog input, 12-bit",
resolution_bits=12,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V", "0-5V", "±5V")),
ModuleType("T1H-08AD-2", "T1H", "analog_input", 8,
"Voltage/current analog input, 15-bit",
resolution_bits=15,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V", "0-5V", "±5V")),
ModuleType("T1H-16AD-1", "T1H", "analog_input", 16,
"Voltage/current analog input, 12-bit",
resolution_bits=12,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V")),
ModuleType("T1H-16AD-2", "T1H", "analog_input", 16,
"Voltage/current analog input, 15-bit",
resolution_bits=15,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V")),
# ANALOG INPUTS — T1K
ModuleType("T1K-08AD-1", "T1K", "analog_input", 8,
"Voltage/current analog input, 12-bit",
resolution_bits=12,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V", "0-5V", "±5V")),
ModuleType("T1K-08AD-2", "T1K", "analog_input", 8,
"Voltage/current analog input, 15-bit",
resolution_bits=15,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V", "0-5V", "±5V")),
# ══════════════════════════════════════════════════════════════════════════
# ANALOG OUTPUTS — T1H
# ══════════════════════════════════════════════════════════════════════════
ModuleType("T1H-04DA-1", "T1H", "analog_output", 4,
"Voltage/current analog output, 12-bit",
resolution_bits=12,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V")),
ModuleType("T1H-04DA-2", "T1H", "analog_output", 4,
"Voltage/current analog output, 15-bit",
resolution_bits=15,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V")),
ModuleType("T1H-08DA-1", "T1H", "analog_output", 8,
"Voltage/current analog output, 12-bit",
resolution_bits=12,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V")),
ModuleType("T1H-08DA-2", "T1H", "analog_output", 8,
"Voltage/current analog output, 15-bit",
resolution_bits=15,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V")),
ModuleType("T1H-16DA-1", "T1H", "analog_output", 16,
"Voltage/current analog output, 12-bit",
resolution_bits=12,
range_options=("0-20mA", "4-20mA", "0-10V")),
ModuleType("T1H-16DA-2", "T1H", "analog_output", 16,
"Voltage/current analog output, 15-bit",
resolution_bits=15,
range_options=("0-20mA", "4-20mA", "0-10V")),
# ANALOG OUTPUTS — T1K
ModuleType("T1K-04DA-1", "T1K", "analog_output", 4,
"Voltage/current analog output, 12-bit",
resolution_bits=12,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V")),
ModuleType("T1K-04DA-2", "T1K", "analog_output", 4,
"Voltage/current analog output, 15-bit",
resolution_bits=15,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V")),
ModuleType("T1K-08DA-1", "T1K", "analog_output", 8,
"Voltage/current analog output, 12-bit",
resolution_bits=12,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V")),
ModuleType("T1K-08DA-2", "T1K", "analog_output", 8,
"Voltage/current analog output, 15-bit",
resolution_bits=15,
range_options=("0-20mA", "4-20mA", "0-10V", "±10V")),
# ══════════════════════════════════════════════════════════════════════════
# TEMPERATURE INPUTS
# ══════════════════════════════════════════════════════════════════════════
ModuleType("T1H-08THM", "T1H", "temperature_input", 8,
"Thermocouple input (J/K/E/T/R/S/N/B)",
resolution_bits=16,
range_options=("type J", "type K", "type E", "type T",
"type R", "type S", "type N", "type B")),
ModuleType("T1H-04RTD", "T1H", "temperature_input", 4,
"RTD input (Pt100/Pt1000/Ni120)",
resolution_bits=16,
range_options=("Pt100", "Pt1000", "Ni120")),
ModuleType("T1K-08THM", "T1K", "temperature_input", 8,
"Thermocouple input (J/K/E/T/R/S/N/B)",
resolution_bits=16,
range_options=("type J", "type K", "type E", "type T",
"type R", "type S", "type N", "type B")),
ModuleType("T1K-04RTD", "T1K", "temperature_input", 4,
"RTD input (Pt100/Pt1000/Ni120)",
resolution_bits=16,
range_options=("Pt100", "Pt1000", "Ni120")),
]
# Build the lookup dict
MODULE_REGISTRY: dict[str, ModuleType] = {m.part_number: m for m in _ALL_MODULES}
def get_module_type(part_number: str) -> ModuleType:
"""Look up a module type by part number. Raises KeyError with helpful message."""
try:
return MODULE_REGISTRY[part_number]
except KeyError:
known = ", ".join(sorted(MODULE_REGISTRY))
raise KeyError(
f"Unknown module type {part_number!r}. "
f"Known types: {known}"
) from None

494
arnold/sequencer.py Normal file
View File

@@ -0,0 +1,494 @@
"""
arnold/sequencer.py — Sequence execution engine.
Design:
- One sequence can run at a time (enforced by a mutex).
- Runs asynchronously in a worker thread; returns a run_id immediately.
- Caller polls GET /runs/{run_id} for result.
- Absolute timing: each step fires at t_start + step.t_ms (monotonic clock).
- check_input: reads from IOState cache (fast-poll value), instant check.
- set_output: calls IODriver.write_output() directly.
- On step failure: remaining steps are skipped, outputs are NOT auto-reset
(caller is responsible for safety; a future "on_abort" hook could be added).
"""
from __future__ import annotations
import json
import logging
import threading
import time
import uuid
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal
if TYPE_CHECKING:
from .config import Config, Sequence, SequenceStep
from .terminator_io import IORegistry
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Run result dataclasses
# ---------------------------------------------------------------------------
@dataclass
class StepResult:
step_index: int
t_ms: int
action: str
signal: str
success: bool
detail: str = "" # human-readable description
actual: bool | int | None = None # for check_input / wait_input
expected: bool | int | None = None
@dataclass
class RunResult:
run_id: str
sequence_name: str
status: Literal["pending", "running", "success", "failed", "error"]
started_at: str = "" # ISO8601
finished_at: str = ""
duration_ms: int = 0
steps_completed: int = 0
total_steps: int = 0
current_step_index: int = -1 # index of the step currently executing (-1 = none)
failed_step: StepResult | None = None
error_message: str = ""
def to_dict(self) -> dict:
d = asdict(self)
return d
# ---------------------------------------------------------------------------
# Run log (JSON-lines file)
# ---------------------------------------------------------------------------
class RunLog:
def __init__(self, path: Path) -> None:
self._path = path
self._lock = threading.Lock()
def append(self, result: RunResult) -> None:
with self._lock:
with open(self._path, "a") as f:
f.write(json.dumps(result.to_dict()) + "\n")
def tail(self, n: int = 50) -> list[dict]:
"""Return the last n entries."""
if not self._path.exists():
return []
with self._lock:
lines = self._path.read_text().splitlines()
entries = []
for line in lines[-n:]:
line = line.strip()
if line:
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
pass
return list(reversed(entries)) # most recent first
# ---------------------------------------------------------------------------
# Sequencer
# ---------------------------------------------------------------------------
class Sequencer:
def __init__(
self,
config: "Config",
registry: "IORegistry",
log_path: Path,
on_output_write: Callable[[str, bool | int], None] | None = None,
) -> None:
self._config = config
self._registry = registry
self._run_log = RunLog(log_path)
# Optional callback fired after every successful set_output step.
# Signature: on_output_write(signal_name: str, value: bool | int)
self._on_output_write = on_output_write
# One-at-a-time enforcement
self._run_lock = threading.Lock()
self._active_id: str | None = None
# Result store: run_id -> RunResult
self._results_lock = threading.Lock()
self._results: dict[str, RunResult] = {}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start(self, sequence_name: str) -> tuple[str, bool]:
"""
Launch sequence_name in a background thread.
Returns (run_id, started).
started=False means another sequence is already running (caller
should return HTTP 409).
"""
seq = self._config.sequence(sequence_name)
if seq is None:
raise ValueError(f"Unknown sequence: {sequence_name!r}")
# Try to acquire run lock non-blocking
if not self._run_lock.acquire(blocking=False):
return ("", False)
run_id = str(uuid.uuid4())
result = RunResult(
run_id=run_id,
sequence_name=sequence_name,
status="pending",
total_steps=len(seq.steps),
)
with self._results_lock:
self._results[run_id] = result
self._active_id = run_id
t = threading.Thread(
target=self._run_thread,
args=(seq, run_id),
name=f"seq-{sequence_name}-{run_id[:8]}",
daemon=True,
)
t.start()
return (run_id, True)
def get_result(self, run_id: str) -> RunResult | None:
with self._results_lock:
return self._results.get(run_id)
def active_run_id(self) -> str | None:
with self._results_lock:
return self._active_id
def recent_runs(self, n: int = 50) -> list[dict]:
return self._run_log.tail(n)
# ------------------------------------------------------------------
# Execution thread
# ------------------------------------------------------------------
def _run_thread(self, seq: "Sequence", run_id: str) -> None:
started_at = datetime.now(timezone.utc)
t_start = time.monotonic()
self._update_result(run_id, status="running",
started_at=started_at.isoformat())
log.info("Sequence %r started run_id=%s steps=%d",
seq.name, run_id, len(seq.steps))
failed_step: StepResult | None = None
steps_completed = 0
try:
for i, step in enumerate(seq.steps):
# Mark which step we're about to execute
self._update_result(run_id, current_step_index=i)
# Wait until absolute time t_ms from start
target = t_start + step.t_ms / 1000.0
now = time.monotonic()
if target > now:
time.sleep(target - now)
ok, step_result = self._execute_step(i, step)
if not ok:
steps_completed = i
failed_step = step_result
log.warning(
"Sequence %r FAILED at step %d (%s %s): %s",
seq.name, i, step.action, step.signal, step_result.detail,
)
break
steps_completed = i + 1
self._update_result(run_id, steps_completed=steps_completed)
log.debug("Step %d OK: %s %s", i, step.action, step.signal)
except Exception as exc:
log.exception("Sequence %r raised exception: %s", seq.name, exc)
finished_at = datetime.now(timezone.utc)
duration_ms = int((time.monotonic() - t_start) * 1000)
result = self._update_result(
run_id,
status="error",
finished_at=finished_at.isoformat(),
duration_ms=duration_ms,
steps_completed=steps_completed,
current_step_index=-1,
error_message=str(exc),
)
self._run_log.append(result)
self._run_lock.release()
with self._results_lock:
self._active_id = None
return
finished_at = datetime.now(timezone.utc)
duration_ms = int((time.monotonic() - t_start) * 1000)
if failed_step:
status = "failed"
else:
status = "success"
steps_completed = len(seq.steps)
result = self._update_result(
run_id,
status=status,
finished_at=finished_at.isoformat(),
duration_ms=duration_ms,
steps_completed=steps_completed,
current_step_index=-1,
failed_step=failed_step,
)
self._run_log.append(result)
self._run_lock.release()
with self._results_lock:
self._active_id = None
log.info(
"Sequence %r %s run_id=%s duration=%dms steps=%d/%d",
seq.name, status.upper(), run_id, duration_ms,
steps_completed, len(seq.steps),
)
# ------------------------------------------------------------------
# Step execution
# ------------------------------------------------------------------
def _execute_step(self, index: int, step: "SequenceStep") -> tuple[bool, StepResult]:
sig = self._config.signal(step.signal)
if sig is None:
# Should never happen — validated at load time
sr = StepResult(index, step.t_ms, step.action, step.signal,
False, f"Unknown signal {step.signal!r}")
return False, sr
if step.action == "set_output":
return self._set_output(index, step, sig)
elif step.action == "check_input":
return self._check_input(index, step, sig)
elif step.action == "wait_input":
return self._wait_input(index, step, sig)
else:
sr = StepResult(index, step.t_ms, step.action, step.signal,
False, f"Unknown action {step.action!r}")
return False, sr
# -- set_output ----------------------------------------------------
def _set_output(
self, index: int, step: "SequenceStep", sig: Any
) -> tuple[bool, StepResult]:
driver = self._registry.driver(sig.device)
if driver is None:
sr = StepResult(index, step.t_ms, step.action, step.signal,
False, f"No driver for device {sig.device!r}")
return False, sr
if sig.value_type == "int":
# Analog output — FC06 (single register write)
write_val: bool | int = int(step.value or 0)
ok = driver.write_register(sig.modbus_address, write_val)
detail_ok = f"Set {step.signal}={write_val}"
detail_err = f"Register write failed for {step.signal}"
else:
# Digital output — FC05 (single coil write)
write_val = bool(step.state)
ok = driver.write_output(sig.modbus_address, write_val)
detail_ok = f"Set {step.signal}={'ON' if write_val else 'OFF'}"
detail_err = f"Coil write failed for {step.signal}"
if ok and self._on_output_write:
try:
self._on_output_write(step.signal, write_val)
except Exception:
pass
sr = StepResult(
step_index=index,
t_ms=step.t_ms,
action=step.action,
signal=step.signal,
success=ok,
detail=detail_ok if ok else detail_err,
)
return ok, sr
# -- check_input ---------------------------------------------------
def _check_input(
self, index: int, step: "SequenceStep", sig: Any
) -> tuple[bool, StepResult]:
actual = self._registry.get_value(step.signal)
stale = self._registry.is_stale(step.signal)
if stale or actual is None:
sr = StepResult(
step_index=index,
t_ms=step.t_ms,
action=step.action,
signal=step.signal,
success=False,
detail=f"Signal {step.signal!r} is stale or not yet read",
actual=actual,
expected=self._expected_for_step(step, sig),
)
return False, sr
ok, expected_display = self._compare(actual, step, sig)
if sig.value_type == "int":
detail = (
f"Check {step.signal}: expected={expected_display} "
f"actual={actual}"
)
else:
detail = (
f"Check {step.signal}: expected={'ON' if step.expected else 'OFF'} "
f"actual={'ON' if actual else 'OFF'}"
)
sr = StepResult(
step_index=index,
t_ms=step.t_ms,
action=step.action,
signal=step.signal,
success=ok,
detail=detail,
actual=actual,
expected=self._expected_for_step(step, sig),
)
return ok, sr
# -- wait_input ----------------------------------------------------
def _wait_input(
self, index: int, step: "SequenceStep", sig: Any
) -> tuple[bool, StepResult]:
"""
Poll the signal cache until the expected value is seen or timeout expires.
Polls every 50 ms. Supports both digital (exact bool match) and
analog (abs(actual - expected_value) <= tolerance) comparisons.
"""
timeout_s = (step.timeout_ms or 0) / 1000.0
deadline = time.monotonic() + timeout_s
poll_interval = 0.05 # 50 ms
exp_display = self._expected_display(step, sig)
while True:
actual = self._registry.get_value(step.signal)
stale = self._registry.is_stale(step.signal)
if not stale and actual is not None:
ok, _ = self._compare(actual, step, sig)
if ok:
sr = StepResult(
step_index=index,
t_ms=step.t_ms,
action=step.action,
signal=step.signal,
success=True,
detail=f"Wait {step.signal}=={exp_display}: condition met",
actual=actual,
expected=self._expected_for_step(step, sig),
)
return True, sr
if time.monotonic() >= deadline:
if stale or actual is None:
act_str = "stale"
elif sig.value_type == "int":
act_str = str(actual)
else:
act_str = "ON" if actual else "OFF"
sr = StepResult(
step_index=index,
t_ms=step.t_ms,
action=step.action,
signal=step.signal,
success=False,
detail=(
f"Wait {step.signal}=={exp_display}: "
f"timeout after {step.timeout_ms} ms (actual={act_str})"
),
actual=actual,
expected=self._expected_for_step(step, sig),
)
return False, sr
time.sleep(poll_interval)
# -- Comparison helpers --------------------------------------------
@staticmethod
def _compare(
actual: bool | int,
step: "SequenceStep",
sig: Any,
) -> tuple[bool, str]:
"""
Compare actual value against step expectation.
Returns (match: bool, expected_display: str).
"""
if sig.value_type == "int":
# Analog comparison with tolerance
expected = step.expected_value if step.expected_value is not None else 0
tolerance = step.tolerance if step.tolerance is not None else 0
ok = abs(int(actual) - expected) <= tolerance
if tolerance > 0:
display = f"{expected}±{tolerance}"
else:
display = str(expected)
return ok, display
else:
# Digital: exact bool match
ok = (actual == step.expected)
display = "ON" if step.expected else "OFF"
return ok, display
@staticmethod
def _expected_for_step(step: "SequenceStep", sig: Any) -> bool | int | None:
"""Return the expected value in the appropriate type for StepResult."""
if sig.value_type == "int":
return step.expected_value
return step.expected
@staticmethod
def _expected_display(step: "SequenceStep", sig: Any) -> str:
"""Human-readable expected value string."""
if sig.value_type == "int":
expected = step.expected_value if step.expected_value is not None else 0
tolerance = step.tolerance if step.tolerance is not None else 0
if tolerance > 0:
return f"{expected}±{tolerance}"
return str(expected)
return "ON" if step.expected else "OFF"
# ------------------------------------------------------------------
# Internal result update
# ------------------------------------------------------------------
def _update_result(self, run_id: str, **kwargs) -> RunResult:
with self._results_lock:
result = self._results[run_id]
for k, v in kwargs.items():
setattr(result, k, v)
return result

663
arnold/terminator_io.py Normal file
View File

@@ -0,0 +1,663 @@
"""
arnold/terminator_io.py — AutomationDirect Terminator I/O driver.
Encapsulates everything that touches a physical T1H-EBC100 controller:
- Modbus TCP connection management (pymodbus, auto-reconnect)
- Signal state cache (thread-safe)
- Background fast-poll thread (reads both coils and registers each cycle)
Key hardware quirks documented here:
- The EBC100 uses a UNIFIED flat coil address space across all digital
modules in physical slot order. FC02 (read discrete inputs) and
FC01/FC05/FC15 (read/write coils) share the same sequential offsets.
If slot 1 and slot 2 are 8-pt input modules (addresses 0-7, 8-15),
a 16-pt output module in slot 3 starts at coil address 16 — NOT 0.
- The EBC100 maintains TWO independent flat address spaces:
coil space (1-bit) — digital modules: FC01/FC02/FC05/FC15
register space (16-bit) — analog + temperature: FC03/FC04/FC06/FC16
A digital module advances only the coil offset; an analog module
advances only the register offset. They do not interfere.
- FC02 (read discrete inputs) returns input bits starting at address 0.
Because input modules always appear first in the unified coil scheme,
the FC02 bit index equals modbus_address for every digital input signal.
- FC04 (read input registers) returns 16-bit values for analog/temperature
input modules, starting at register address 0 in the register space.
- The EBC100 never raises Modbus exception code 2 (illegal address) for
out-of-range reads — it silently returns zeros. Module presence cannot
be auto-detected via protocol errors; use the config 'modules' list.
- The EBC100 responds to any Modbus unit/slave ID over TCP — the unit_id
field is echoed back but not used for routing. Set it to 1 (default).
- FC05 write_coil echoes back True for any address, even unmapped ones.
There is no write-error feedback for out-of-range output addresses.
- The device has no unsolicited push capability. Polling is mandatory.
Public API
----------
TerminatorIO(device: DeviceConfig)
.connect() -> bool
.disconnect()
.read_inputs() -> list[bool] | None # bulk FC02, digital inputs
.read_registers(address, count) -> list[int] | None # bulk FC04, analog inputs
.write_output(address, value) -> bool # FC05 single coil
.write_outputs(address, values) -> bool # FC15 multiple coils
.write_register(address, value) -> bool # FC06 single register
.write_registers(address, values) -> bool # FC16 multiple registers
.connected: bool
.status() -> dict
SignalState dataclass: name, value (bool|int), updated_at, stale
IORegistry(config) multi-device coordinator
.start() connect + start all poll threads
.stop() stop all poll threads + disconnect
.get(signal) -> SignalState | None
.get_value(signal) -> bool | int | None
.snapshot() -> dict[str, SignalState]
.poll_stats() -> list[dict]
.driver_status() -> list[dict]
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
if TYPE_CHECKING:
from .config import Config, DeviceConfig, LogicalIO
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Signal state
# ---------------------------------------------------------------------------
@dataclass
class SignalState:
name: str
value: bool | int
updated_at: float # time.monotonic()
stale: bool = False # True when the last poll for this device failed
# ---------------------------------------------------------------------------
# TerminatorIO — one instance per physical EBC100 controller
# ---------------------------------------------------------------------------
class TerminatorIO:
"""
Modbus TCP driver for a single T1H-EBC100 controller.
Thread-safe: all public methods acquire an internal lock. The poll
thread holds the lock only for the duration of each FC02 call, so
write_output() will block at most one poll cycle (~50 ms).
"""
def __init__(self, device: "DeviceConfig") -> None:
self.device = device
self._lock = threading.Lock()
self._client: ModbusTcpClient | None = None
self._connected = False
self._connect_attempts = 0
self._last_connect_error = ""
# ------------------------------------------------------------------
# Connection
# ------------------------------------------------------------------
def connect(self) -> bool:
"""Open the Modbus TCP connection. Returns True on success."""
with self._lock:
return self._connect_locked()
def _connect_locked(self) -> bool:
if self._client is not None:
try:
self._client.close()
except Exception:
pass
self._client = ModbusTcpClient(
host=self.device.host,
port=self.device.port,
timeout=2,
retries=1,
)
self._connect_attempts += 1
ok = self._client.connect()
self._connected = ok
if ok:
log.info("Connected to %s (%s:%d)",
self.device.id, self.device.host, self.device.port)
else:
self._last_connect_error = (
f"TCP connect failed to {self.device.host}:{self.device.port}"
)
log.warning("Could not connect to %s: %s",
self.device.id, self._last_connect_error)
return ok
def disconnect(self) -> None:
with self._lock:
if self._client:
try:
self._client.close()
except Exception:
pass
self._connected = False
self._client = None
@property
def connected(self) -> bool:
return self._connected
# ------------------------------------------------------------------
# Read inputs — single bulk FC02 request for all input modules
# ------------------------------------------------------------------
def read_inputs(self) -> list[bool] | None:
"""
Read all discrete input points in one FC02 request.
Returns a flat list of bool ordered by slot then point (matching
the unified address scheme), or None on comms error.
FC02 returns input bits starting at address 0. Because input modules
are always at lower slot numbers than output modules (enforced by the
unified address scheme), the FC02 bit index equals modbus_address for
every input signal.
"""
total = self.device.total_input_points()
if total == 0:
return []
with self._lock:
return self._fc02_locked(address=0, count=total)
def _fc02_locked(self, address: int, count: int) -> list[bool] | None:
for attempt in range(2):
if not self._connected:
if not self._connect_locked():
return None
try:
rr = self._client.read_discrete_inputs(
address=address, count=count,
device_id=self.device.unit_id,
)
if rr.isError() or isinstance(rr, ExceptionResponse):
log.warning("%s FC02 error: %s", self.device.id, rr)
self._connected = False
continue
return list(rr.bits[:count])
except (ModbusException, ConnectionError, OSError) as exc:
log.warning("%s read error (attempt %d): %s",
self.device.id, attempt + 1, exc)
self._connected = False
time.sleep(0.05)
return None
# ------------------------------------------------------------------
# Read analog input registers — single bulk FC04 request
# ------------------------------------------------------------------
def read_registers(self, address: int, count: int) -> list[int] | None:
"""
Read contiguous 16-bit input registers via FC04.
Used for analog and temperature input modules whose signals live
in the register address space. Returns a list of raw int values
(065535), or None on comms error.
"""
if count == 0:
return []
with self._lock:
return self._fc04_locked(address, count)
def _fc04_locked(self, address: int, count: int) -> list[int] | None:
for attempt in range(2):
if not self._connected:
if not self._connect_locked():
return None
try:
rr = self._client.read_input_registers(
address=address, count=count,
device_id=self.device.unit_id,
)
if rr.isError() or isinstance(rr, ExceptionResponse):
log.warning("%s FC04 error: %s", self.device.id, rr)
self._connected = False
continue
return list(rr.registers[:count])
except (ModbusException, ConnectionError, OSError) as exc:
log.warning("%s FC04 read error (attempt %d): %s",
self.device.id, attempt + 1, exc)
self._connected = False
time.sleep(0.05)
return None
# ------------------------------------------------------------------
# Write digital outputs
# ------------------------------------------------------------------
def write_output(self, address: int, value: bool) -> bool:
"""
Write a single coil via FC05.
Address is the unified slot-order coil address (as stored in
LogicalIO.modbus_address). Returns True on success.
Note: the EBC100 echoes True for any address — write errors for
out-of-range addresses are silent. Config validation prevents
invalid addresses at startup.
"""
with self._lock:
return self._fc05_locked(address, value)
def _fc05_locked(self, address: int, value: bool) -> bool:
for attempt in range(2):
if not self._connected:
if not self._connect_locked():
return False
try:
rr = self._client.write_coil(
address=address, value=value,
device_id=self.device.unit_id,
)
if rr.isError() or isinstance(rr, ExceptionResponse):
log.warning("%s FC05 error addr=%d: %s",
self.device.id, address, rr)
self._connected = False
continue
log.debug("%s coil[%d] = %s", self.device.id, address, value)
return True
except (ModbusException, ConnectionError, OSError) as exc:
log.warning("%s write error (attempt %d): %s",
self.device.id, attempt + 1, exc)
self._connected = False
time.sleep(0.05)
return False
def write_outputs(self, address: int, values: list[bool]) -> bool:
"""Write multiple contiguous coils via FC15."""
with self._lock:
for attempt in range(2):
if not self._connected:
if not self._connect_locked():
return False
try:
rr = self._client.write_coils(
address=address, values=values,
device_id=self.device.unit_id,
)
if rr.isError() or isinstance(rr, ExceptionResponse):
log.warning("%s FC15 error addr=%d: %s",
self.device.id, address, rr)
self._connected = False
continue
return True
except (ModbusException, ConnectionError, OSError) as exc:
log.warning("%s write_coils error (attempt %d): %s",
self.device.id, attempt + 1, exc)
self._connected = False
time.sleep(0.05)
return False
# ------------------------------------------------------------------
# Write analog outputs
# ------------------------------------------------------------------
def write_register(self, address: int, value: int) -> bool:
"""
Write a single 16-bit holding register via FC06.
Address is the register-space address (as stored in
LogicalIO.modbus_address for analog output signals).
value is a raw 16-bit integer (065535).
"""
with self._lock:
return self._fc06_locked(address, value)
def _fc06_locked(self, address: int, value: int) -> bool:
for attempt in range(2):
if not self._connected:
if not self._connect_locked():
return False
try:
rr = self._client.write_register(
address=address, value=value,
device_id=self.device.unit_id,
)
if rr.isError() or isinstance(rr, ExceptionResponse):
log.warning("%s FC06 error addr=%d: %s",
self.device.id, address, rr)
self._connected = False
continue
log.debug("%s reg[%d] = %d", self.device.id, address, value)
return True
except (ModbusException, ConnectionError, OSError) as exc:
log.warning("%s FC06 write error (attempt %d): %s",
self.device.id, attempt + 1, exc)
self._connected = False
time.sleep(0.05)
return False
def write_registers(self, address: int, values: list[int]) -> bool:
"""Write multiple contiguous 16-bit holding registers via FC16."""
with self._lock:
for attempt in range(2):
if not self._connected:
if not self._connect_locked():
return False
try:
rr = self._client.write_registers(
address=address, values=values,
device_id=self.device.unit_id,
)
if rr.isError() or isinstance(rr, ExceptionResponse):
log.warning("%s FC16 error addr=%d: %s",
self.device.id, address, rr)
self._connected = False
continue
return True
except (ModbusException, ConnectionError, OSError) as exc:
log.warning("%s FC16 write error (attempt %d): %s",
self.device.id, attempt + 1, exc)
self._connected = False
time.sleep(0.05)
return False
# ------------------------------------------------------------------
# Status
# ------------------------------------------------------------------
def status(self) -> dict:
return {
"device_id": self.device.id,
"host": self.device.host,
"port": self.device.port,
"connected": self._connected,
"connect_attempts": self._connect_attempts,
"last_error": self._last_connect_error or None,
}
# ---------------------------------------------------------------------------
# _PollThread — internal; one per TerminatorIO instance
# ---------------------------------------------------------------------------
class _PollThread(threading.Thread):
"""
Reads all input points from one EBC100 at poll_interval_ms, updates the
shared signal cache. Daemon thread — exits when the process does.
Each poll cycle reads BOTH address spaces:
- FC02 (coil space): digital input signals → list[bool]
- FC04 (register space): analog/temperature input signals → list[int]
"""
def __init__(
self,
driver: TerminatorIO,
digital_signals: list["LogicalIO"], # digital input signals, sorted by modbus_address
analog_signals: list["LogicalIO"], # analog/temp input signals, sorted by modbus_address
cache: dict[str, SignalState],
lock: threading.Lock,
) -> None:
super().__init__(name=f"poll-{driver.device.id}", daemon=True)
self._driver = driver
self._digital_signals = digital_signals
self._analog_signals = analog_signals
self._cache = cache
self._lock = lock
self._stop = threading.Event()
self.poll_count = 0
self.error_count = 0
self._achieved_hz: float = 0.0
self._last_poll_ts: float | None = None
@property
def _total_signals(self) -> int:
return len(self._digital_signals) + len(self._analog_signals)
def stop(self) -> None:
self._stop.set()
def run(self) -> None:
interval = self._driver.device.poll_interval_ms / 1000.0
log.info("Poll thread started: %s %.0f ms interval %d digital + %d analog signals",
self._driver.device.id,
self._driver.device.poll_interval_ms,
len(self._digital_signals),
len(self._analog_signals))
self._driver.connect()
rate_t0 = time.monotonic()
rate_polls = 0
while not self._stop.is_set():
t0 = time.monotonic()
self._cycle()
rate_polls += 1
self.poll_count += 1
elapsed = time.monotonic() - t0
# Update achieved rate every 5 s
window = time.monotonic() - rate_t0
if window >= 5.0:
self._achieved_hz = rate_polls / window
log.debug("%s %.1f polls/s errors=%d",
self._driver.device.id,
self._achieved_hz, self.error_count)
rate_t0 = time.monotonic()
rate_polls = 0
wait = interval - elapsed
if wait > 0:
self._stop.wait(wait)
log.info("Poll thread stopped: %s", self._driver.device.id)
self._driver.disconnect()
def _cycle(self) -> None:
if not self._digital_signals and not self._analog_signals:
return
had_error = False
updates: dict[str, SignalState] = {}
now = time.monotonic()
# ── Digital inputs (FC02, coil space) ─────────────────────────
if self._digital_signals:
bits = self._driver.read_inputs()
if bits is None:
had_error = True
for sig in self._digital_signals:
existing = self._cache.get(sig.name)
updates[sig.name] = SignalState(
name=sig.name,
value=existing.value if existing else False,
updated_at=existing.updated_at if existing else now,
stale=True,
)
else:
for sig in self._digital_signals:
if sig.modbus_address < len(bits):
updates[sig.name] = SignalState(
name=sig.name,
value=bool(bits[sig.modbus_address]),
updated_at=now,
stale=False,
)
else:
log.warning("%s signal %r addr %d out of range (%d bits)",
self._driver.device.id, sig.name,
sig.modbus_address, len(bits))
# ── Analog / temperature inputs (FC04, register space) ────────
if self._analog_signals:
total_regs = self._driver.device.total_analog_input_channels()
regs = self._driver.read_registers(address=0, count=total_regs)
if regs is None:
had_error = True
for sig in self._analog_signals:
existing = self._cache.get(sig.name)
updates[sig.name] = SignalState(
name=sig.name,
value=existing.value if existing else 0,
updated_at=existing.updated_at if existing else now,
stale=True,
)
else:
for sig in self._analog_signals:
if sig.modbus_address < len(regs):
updates[sig.name] = SignalState(
name=sig.name,
value=int(regs[sig.modbus_address]),
updated_at=now,
stale=False,
)
else:
log.warning("%s signal %r reg addr %d out of range (%d regs)",
self._driver.device.id, sig.name,
sig.modbus_address, len(regs))
if had_error:
self.error_count += 1
self._last_poll_ts = now
with self._lock:
self._cache.update(updates)
def stats(self) -> dict:
return {
"device_id": self._driver.device.id,
"poll_count": self.poll_count,
"error_count": self.error_count,
"achieved_hz": round(self._achieved_hz, 1),
"target_hz": round(1000 / self._driver.device.poll_interval_ms, 1),
"last_poll_ts": self._last_poll_ts,
"running": self.is_alive(),
}
# ---------------------------------------------------------------------------
# IORegistry — multi-device coordinator (replaces PollManager + driver dict)
# ---------------------------------------------------------------------------
class IORegistry:
"""
Owns all TerminatorIO drivers and poll threads for the full config.
Usage:
registry = IORegistry(config)
registry.start() # connect + begin polling
...
val = registry.get_value("my_signal")
registry.stop()
"""
def __init__(self, config: "Config") -> None:
self._config = config
self._cache: dict[str, SignalState] = {}
self._lock = threading.Lock()
# Build one TerminatorIO + one _PollThread per device
self._drivers: dict[str, TerminatorIO] = {}
self._pollers: list[_PollThread] = []
for device in config.devices:
driver = TerminatorIO(device)
self._drivers[device.id] = driver
# Partition input signals by address space
digital_inputs = sorted(
(s for s in config.logical_io
if s.device == device.id
and s.direction == "input"
and s.modbus_space == "coil"),
key=lambda s: s.modbus_address,
)
analog_inputs = sorted(
(s for s in config.logical_io
if s.device == device.id
and s.direction == "input"
and s.modbus_space == "register"),
key=lambda s: s.modbus_address,
)
poller = _PollThread(
driver, digital_inputs, analog_inputs,
self._cache, self._lock,
)
self._pollers.append(poller)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def start(self) -> None:
"""Start all poll threads (each connects its own driver on first cycle)."""
for p in self._pollers:
p.start()
def stop(self) -> None:
"""Stop all poll threads and disconnect all drivers."""
for p in self._pollers:
p.stop()
for p in self._pollers:
p.join(timeout=3)
# ------------------------------------------------------------------
# Signal reads (used by sequencer + API)
# ------------------------------------------------------------------
def get(self, signal_name: str) -> SignalState | None:
with self._lock:
return self._cache.get(signal_name)
def get_value(self, signal_name: str) -> bool | int | None:
with self._lock:
s = self._cache.get(signal_name)
return s.value if s is not None else None
def is_stale(self, signal_name: str) -> bool:
with self._lock:
s = self._cache.get(signal_name)
return s.stale if s is not None else True
def snapshot(self) -> dict[str, SignalState]:
"""Shallow copy of the full signal cache."""
with self._lock:
return dict(self._cache)
# ------------------------------------------------------------------
# Output writes (used by sequencer)
# ------------------------------------------------------------------
def driver(self, device_id: str) -> TerminatorIO | None:
return self._drivers.get(device_id)
# ------------------------------------------------------------------
# Status / stats
# ------------------------------------------------------------------
def driver_status(self) -> list[dict]:
return [d.status() for d in self._drivers.values()]
def poll_stats(self) -> list[dict]:
return [p.stats() for p in self._pollers]