Files
SequencerIO/arnold/terminator_io.py

664 lines
26 KiB
Python
Raw Normal View History

2026-03-02 17:48:55 -05:00
"""
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]