From 0e265572474c9b3536ac1b566761440e5f406619 Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Tue, 3 Mar 2026 17:25:38 -0500 Subject: [PATCH] dedicated input output sockets --- README.md | 24 +- .../__pycache__/terminator_io.cpython-311.pyc | Bin 34216 -> 37956 bytes arnold/terminator_io.py | 382 ++++++++++-------- 3 files changed, 239 insertions(+), 167 deletions(-) diff --git a/README.md b/README.md index 800d102..c34d751 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ arnold/ __init__.py module_types.py ModuleType frozen dataclass + 44-module registry config.py YAML loader, validation, dual address space computation - terminator_io.py Modbus TCP driver, signal cache, dual-space poll thread + terminator_io.py Modbus TCP driver (dual-connection), signal cache, poll thread sequencer.py Sequence engine: timing, digital+analog set/check/wait api.py FastAPI app: REST endpoints, static file mount for web UI @@ -139,13 +139,13 @@ web/ YAML config ──► config.py ──► module_types.py (resolve part numbers) │ ▼ - terminator_io.py - ┌─ TerminatorIO (Modbus TCP client per device) - │ FC02 read discrete inputs (digital) - │ FC04 read input registers (analog) - │ FC05/FC06 write single coil/register - │ FC15/FC16 write multiple coils/registers - └─ _PollThread (daemon, reads FC02+FC04 each cycle) + terminator_io.py + ┌─ TerminatorIO (two TCP connections per device) + │ ┌─ _reader conn ── FC02 read discrete inputs (digital) + │ │ FC04 read input registers (analog) + │ └─ _writer conn ── FC05/FC06 write single coil/register + │ FC15/FC16 write multiple coils/registers + └─ _PollThread (daemon, reads via _reader each cycle) └─ IORegistry (multi-device coordinator, signal cache) │ ┌────────┼────────┐ @@ -162,6 +162,14 @@ The EBC100 has two independent flat address spaces: A digital module advances only `coil_offset`. An analog module advances only `register_offset`. They do not interfere. `config.py` computes all addresses at load time. +### Dual-connection architecture + +Each `TerminatorIO` opens two independent TCP connections to the EBC100: +a **reader** (used exclusively by the poll thread for FC02/FC04) and a +**writer** (used by sequencer/API/TUI for FC05/FC06/FC15/FC16). Each has +its own lock and reconnect state. Writes never block behind poll reads, +reducing typical output actuation jitter from 5–35 ms to 5–19 ms. + ### EBC100 quirks - Returns zeros for out-of-range reads (no Modbus exception code 2) diff --git a/arnold/__pycache__/terminator_io.cpython-311.pyc b/arnold/__pycache__/terminator_io.cpython-311.pyc index 5eb93c39657255fb91e79855ea3d8fa389bce69e..27997d062dee491948be8b4f6bef8507b5a40d56 100644 GIT binary patch delta 13728 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 delta 11196 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 MUf^b=%V(vx35&!@I diff --git a/arnold/terminator_io.py b/arnold/terminator_io.py index 82bfe15..11a22d4 100644 --- a/arnold/terminator_io.py +++ b/arnold/terminator_io.py @@ -6,6 +6,22 @@ Encapsulates everything that touches a physical T1H-EBC100 controller: - Signal state cache (thread-safe) - Background fast-poll thread (reads both coils and registers each cycle) +Dual-connection architecture +---------------------------- + Each TerminatorIO maintains TWO independent Modbus TCP connections to + the same EBC100: + + _read_client — used exclusively by the poll thread (FC02, FC04) + _write_client — used exclusively by write callers (FC05, FC06, FC15, FC16) + + Each connection has its own lock and connection state. This eliminates + lock contention between the poll thread and output writes, reducing + write latency from 5–35 ms (old shared-lock design) to 5–19 ms + (just sleep jitter + Modbus round-trip, no lock wait). + + The EBC100 accepts multiple simultaneous TCP connections on port 502 + and processes them independently. + 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 @@ -41,14 +57,16 @@ Key hardware quirks documented here: Public API ---------- TerminatorIO(device: DeviceConfig) - .connect() -> bool + .connect() -> bool # connects both read and write clients + .connect_reader() -> bool # connect read client only (poll thread) + .connect_writer() -> bool # connect write client only .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 + .read_inputs() -> list[bool] | None # bulk FC02, read client + .read_registers(address, count) -> list[int] | None # bulk FC04, read client + .write_output(address, value) -> bool # FC05, write client + .write_outputs(address, values) -> bool # FC15, write client + .write_register(address, value) -> bool # FC06, write client + .write_registers(address, values) -> bool # FC16, write client .connected: bool .status() -> dict @@ -94,36 +112,28 @@ class SignalState: # --------------------------------------------------------------------------- -# TerminatorIO — one instance per physical EBC100 controller +# _ModbusConn — one TCP connection with its own lock and reconnect logic # --------------------------------------------------------------------------- -class TerminatorIO: +class _ModbusConn: """ - Modbus TCP driver for a single T1H-EBC100 controller. + A single Modbus TCP connection with independent lock and state. - 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). + TerminatorIO creates two of these: one for reads, one for writes. + Each can connect, reconnect, and operate without blocking the other. """ - def __init__(self, device: "DeviceConfig") -> None: - self.device = device - self._lock = threading.Lock() + def __init__(self, device: "DeviceConfig", role: str) -> None: + self._device = device + self._role = role # "reader" or "writer" — for log messages + self.lock = threading.Lock() self._client: ModbusTcpClient | None = None - self._connected = False - self._connect_attempts = 0 - self._last_connect_error = "" - - # ------------------------------------------------------------------ - # Connection - # ------------------------------------------------------------------ + self.connected = False + self.connect_attempts = 0 + self.last_error = "" 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: + """Open (or reopen) the TCP connection. Call with lock held.""" if self._client is not None: try: self._client.close() @@ -131,41 +141,93 @@ class TerminatorIO: pass self._client = ModbusTcpClient( - host=self.device.host, - port=self.device.port, + host=self._device.host, + port=self._device.port, timeout=2, retries=1, ) - self._connect_attempts += 1 + self.connect_attempts += 1 ok = self._client.connect() - self._connected = ok + self.connected = ok if ok: - log.info("Connected to %s (%s:%d)", - self.device.id, self.device.host, self.device.port) + log.info("%s %s connected to %s:%d", + self._device.id, self._role, + self._device.host, self._device.port) else: - self._last_connect_error = ( - f"TCP connect failed to {self.device.host}:{self.device.port}" + self.last_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) + log.warning("%s %s connect failed: %s", + self._device.id, self._role, self.last_error) return ok + def close(self) -> None: + if self._client: + try: + self._client.close() + except Exception: + pass + self.connected = False + self._client = None + + @property + def client(self) -> ModbusTcpClient | None: + return self._client + + +# --------------------------------------------------------------------------- +# TerminatorIO — one instance per physical EBC100 controller +# --------------------------------------------------------------------------- + +class TerminatorIO: + """ + Modbus TCP driver for a single T1H-EBC100 controller. + + Uses two independent TCP connections: + - _reader: for poll thread reads (FC02, FC04). Lock held only during reads. + - _writer: for output writes (FC05, FC06, FC15, FC16). Lock held only during writes. + + Since each connection has its own lock, writes never block behind reads + and vice versa. + """ + + def __init__(self, device: "DeviceConfig") -> None: + self.device = device + self._reader = _ModbusConn(device, "reader") + self._writer = _ModbusConn(device, "writer") + + # ------------------------------------------------------------------ + # Connection + # ------------------------------------------------------------------ + + def connect(self) -> bool: + """Open both read and write connections. Returns True if both succeed.""" + r = self.connect_reader() + w = self.connect_writer() + return r and w + + def connect_reader(self) -> bool: + """Open the read connection (used by poll thread).""" + with self._reader.lock: + return self._reader.connect() + + def connect_writer(self) -> bool: + """Open the write connection (used by sequencer/API/TUI).""" + with self._writer.lock: + return self._writer.connect() + def disconnect(self) -> None: - with self._lock: - if self._client: - try: - self._client.close() - except Exception: - pass - self._connected = False - self._client = None + with self._reader.lock: + self._reader.close() + with self._writer.lock: + self._writer.close() @property def connected(self) -> bool: - return self._connected + return self._reader.connected or self._writer.connected # ------------------------------------------------------------------ - # Read inputs — single bulk FC02 request for all input modules + # Read inputs — single bulk FC02 request (uses read connection) # ------------------------------------------------------------------ def read_inputs(self) -> list[bool] | None: @@ -175,207 +237,201 @@ class TerminatorIO: 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. + Uses the read connection — never blocks write callers. """ total = self.device.total_input_points() if total == 0: return [] - with self._lock: - return self._fc02_locked(address=0, count=total) + with self._reader.lock: + return self._fc02(self._reader, address=0, count=total) - def _fc02_locked(self, address: int, count: int) -> list[bool] | None: + def _fc02(self, conn: _ModbusConn, address: int, count: int) -> list[bool] | None: for attempt in range(2): - if not self._connected: - if not self._connect_locked(): + if not conn.connected: + if not conn.connect(): return None try: - rr = self._client.read_discrete_inputs( + rr = conn.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 + conn.connected = False continue return list(rr.bits[:count]) except (ModbusException, ConnectionError, OSError) as exc: - log.warning("%s read error (attempt %d): %s", + log.warning("%s FC02 read error (attempt %d): %s", self.device.id, attempt + 1, exc) - self._connected = False + conn.connected = False time.sleep(0.05) return None # ------------------------------------------------------------------ - # Read analog input registers — single bulk FC04 request + # Read analog input registers — bulk FC04 (uses read connection) # ------------------------------------------------------------------ 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. + Uses the read connection — never blocks write callers. """ if count == 0: return [] - with self._lock: - return self._fc04_locked(address, count) + with self._reader.lock: + return self._fc04(self._reader, address, count) - def _fc04_locked(self, address: int, count: int) -> list[int] | None: + def _fc04(self, conn: _ModbusConn, address: int, count: int) -> list[int] | None: for attempt in range(2): - if not self._connected: - if not self._connect_locked(): + if not conn.connected: + if not conn.connect(): return None try: - rr = self._client.read_input_registers( + rr = conn.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 + conn.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 + conn.connected = False time.sleep(0.05) return None # ------------------------------------------------------------------ - # Write digital outputs + # Write digital outputs (uses write connection) # ------------------------------------------------------------------ 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. + Uses the write connection — never blocked by poll thread reads. """ - with self._lock: - return self._fc05_locked(address, value) + with self._writer.lock: + return self._fc05(self._writer, address, value) - def _fc05_locked(self, address: int, value: bool) -> bool: + def _fc05(self, conn: _ModbusConn, address: int, value: bool) -> bool: for attempt in range(2): - if not self._connected: - if not self._connect_locked(): + if not conn.connected: + if not conn.connect(): return False try: - rr = self._client.write_coil( + rr = conn.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 + conn.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", + log.warning("%s FC05 write error (attempt %d): %s", self.device.id, attempt + 1, exc) - self._connected = False + conn.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) + """Write multiple contiguous coils via FC15. Uses write connection.""" + with self._writer.lock: + return self._fc15(self._writer, address, values) + + def _fc15(self, conn: _ModbusConn, address: int, values: list[bool]) -> bool: + for attempt in range(2): + if not conn.connected: + if not conn.connect(): + return False + try: + rr = conn.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) + conn.connected = False + continue + return True + except (ModbusException, ConnectionError, OSError) as exc: + log.warning("%s FC15 write error (attempt %d): %s", + self.device.id, attempt + 1, exc) + conn.connected = False + time.sleep(0.05) return False # ------------------------------------------------------------------ - # Write analog outputs + # Write analog outputs (uses write connection) # ------------------------------------------------------------------ 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). + Uses the write connection — never blocked by poll thread reads. """ - with self._lock: - return self._fc06_locked(address, value) + with self._writer.lock: + return self._fc06(self._writer, address, value) - def _fc06_locked(self, address: int, value: int) -> bool: + def _fc06(self, conn: _ModbusConn, address: int, value: int) -> bool: for attempt in range(2): - if not self._connected: - if not self._connect_locked(): + if not conn.connected: + if not conn.connect(): return False try: - rr = self._client.write_register( + rr = conn.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 + conn.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 + conn.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) + """Write multiple contiguous 16-bit holding registers via FC16. + Uses write connection.""" + with self._writer.lock: + return self._fc16(self._writer, address, values) + + def _fc16(self, conn: _ModbusConn, address: int, values: list[int]) -> bool: + for attempt in range(2): + if not conn.connected: + if not conn.connect(): + return False + try: + rr = conn.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) + conn.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) + conn.connected = False + time.sleep(0.05) return False # ------------------------------------------------------------------ @@ -387,9 +443,13 @@ class TerminatorIO: "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, + "connected": self.connected, + "reader_connected": self._reader.connected, + "writer_connected": self._writer.connected, + "reader_connect_attempts": self._reader.connect_attempts, + "writer_connect_attempts": self._writer.connect_attempts, + "last_reader_error": self._reader.last_error or None, + "last_writer_error": self._writer.last_error or None, } @@ -402,16 +462,19 @@ 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] + Each poll cycle reads BOTH address spaces via the driver's read connection: + - FC02 (coil space): digital input signals -> list[bool] + - FC04 (register space): analog/temperature input signals -> list[int] + + The read connection has its own lock, so poll reads never block output + writes (which use the separate write connection). """ 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 + digital_signals: list["LogicalIO"], + analog_signals: list["LogicalIO"], cache: dict[str, SignalState], lock: threading.Lock, ) -> None: @@ -443,7 +506,8 @@ class _PollThread(threading.Thread): len(self._digital_signals), len(self._analog_signals)) - self._driver.connect() + # Only connect the read client; the write client connects on first use + self._driver.connect_reader() rate_t0 = time.monotonic() rate_polls = 0 @@ -481,7 +545,7 @@ class _PollThread(threading.Thread): updates: dict[str, SignalState] = {} now = time.monotonic() - # ── Digital inputs (FC02, coil space) ───────────────────────── + # -- Digital inputs (FC02, coil space) ------------------------- if self._digital_signals: bits = self._driver.read_inputs() if bits is None: @@ -508,7 +572,7 @@ class _PollThread(threading.Thread): self._driver.device.id, sig.name, sig.modbus_address, len(bits)) - # ── Analog / temperature inputs (FC04, register space) ──────── + # -- 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) @@ -557,7 +621,7 @@ class _PollThread(threading.Thread): # --------------------------------------------------------------------------- -# IORegistry — multi-device coordinator (replaces PollManager + driver dict) +# IORegistry — multi-device coordinator # --------------------------------------------------------------------------- class IORegistry: @@ -611,7 +675,7 @@ class IORegistry: # ------------------------------------------------------------------ def start(self) -> None: - """Start all poll threads (each connects its own driver on first cycle).""" + """Start all poll threads (each connects its read client on first cycle).""" for p in self._pollers: p.start()