dedicated input output sockets

This commit is contained in:
2026-03-03 17:25:38 -05:00
parent cec6ef8882
commit 0e26557247
3 changed files with 239 additions and 167 deletions

View File

@@ -123,7 +123,7 @@ arnold/
__init__.py __init__.py
module_types.py ModuleType frozen dataclass + 44-module registry module_types.py ModuleType frozen dataclass + 44-module registry
config.py YAML loader, validation, dual address space computation 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 sequencer.py Sequence engine: timing, digital+analog set/check/wait
api.py FastAPI app: REST endpoints, static file mount for web UI 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) YAML config ──► config.py ──► module_types.py (resolve part numbers)
terminator_io.py terminator_io.py
┌─ TerminatorIO (Modbus TCP client per device) ┌─ TerminatorIO (two TCP connections per device)
FC02 read discrete inputs (digital) ┌─ _reader conn ── FC02 read discrete inputs (digital)
│ FC04 read input registers (analog) FC04 read input registers (analog)
FC05/FC06 write single coil/register └─ _writer conn ── FC05/FC06 write single coil/register
│ FC15/FC16 write multiple coils/registers FC15/FC16 write multiple coils/registers
└─ _PollThread (daemon, reads FC02+FC04 each cycle) └─ _PollThread (daemon, reads via _reader each cycle)
└─ IORegistry (multi-device coordinator, signal cache) └─ 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. 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 535 ms to 519 ms.
### EBC100 quirks ### EBC100 quirks
- Returns zeros for out-of-range reads (no Modbus exception code 2) - Returns zeros for out-of-range reads (no Modbus exception code 2)

View File

@@ -6,6 +6,22 @@ Encapsulates everything that touches a physical T1H-EBC100 controller:
- Signal state cache (thread-safe) - Signal state cache (thread-safe)
- Background fast-poll thread (reads both coils and registers each cycle) - 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 535 ms (old shared-lock design) to 519 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: Key hardware quirks documented here:
- The EBC100 uses a UNIFIED flat coil address space across all digital - The EBC100 uses a UNIFIED flat coil address space across all digital
modules in physical slot order. FC02 (read discrete inputs) and modules in physical slot order. FC02 (read discrete inputs) and
@@ -41,14 +57,16 @@ Key hardware quirks documented here:
Public API Public API
---------- ----------
TerminatorIO(device: DeviceConfig) 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() .disconnect()
.read_inputs() -> list[bool] | None # bulk FC02, digital inputs .read_inputs() -> list[bool] | None # bulk FC02, read client
.read_registers(address, count) -> list[int] | None # bulk FC04, analog inputs .read_registers(address, count) -> list[int] | None # bulk FC04, read client
.write_output(address, value) -> bool # FC05 single coil .write_output(address, value) -> bool # FC05, write client
.write_outputs(address, values) -> bool # FC15 multiple coils .write_outputs(address, values) -> bool # FC15, write client
.write_register(address, value) -> bool # FC06 single register .write_register(address, value) -> bool # FC06, write client
.write_registers(address, values) -> bool # FC16 multiple registers .write_registers(address, values) -> bool # FC16, write client
.connected: bool .connected: bool
.status() -> dict .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 TerminatorIO creates two of these: one for reads, one for writes.
thread holds the lock only for the duration of each FC02 call, so Each can connect, reconnect, and operate without blocking the other.
write_output() will block at most one poll cycle (~50 ms).
""" """
def __init__(self, device: "DeviceConfig") -> None: def __init__(self, device: "DeviceConfig", role: str) -> None:
self.device = device self._device = device
self._lock = threading.Lock() self._role = role # "reader" or "writer" — for log messages
self.lock = threading.Lock()
self._client: ModbusTcpClient | None = None self._client: ModbusTcpClient | None = None
self._connected = False self.connected = False
self._connect_attempts = 0 self.connect_attempts = 0
self._last_connect_error = "" self.last_error = ""
# ------------------------------------------------------------------
# Connection
# ------------------------------------------------------------------
def connect(self) -> bool: def connect(self) -> bool:
"""Open the Modbus TCP connection. Returns True on success.""" """Open (or reopen) the TCP connection. Call with lock held."""
with self._lock:
return self._connect_locked()
def _connect_locked(self) -> bool:
if self._client is not None: if self._client is not None:
try: try:
self._client.close() self._client.close()
@@ -131,41 +141,93 @@ class TerminatorIO:
pass pass
self._client = ModbusTcpClient( self._client = ModbusTcpClient(
host=self.device.host, host=self._device.host,
port=self.device.port, port=self._device.port,
timeout=2, timeout=2,
retries=1, retries=1,
) )
self._connect_attempts += 1 self.connect_attempts += 1
ok = self._client.connect() ok = self._client.connect()
self._connected = ok self.connected = ok
if ok: if ok:
log.info("Connected to %s (%s:%d)", log.info("%s %s connected to %s:%d",
self.device.id, self.device.host, self.device.port) self._device.id, self._role,
self._device.host, self._device.port)
else: else:
self._last_connect_error = ( self.last_error = (
f"TCP connect failed to {self.device.host}:{self.device.port}" f"TCP connect failed to {self._device.host}:{self._device.port}"
) )
log.warning("Could not connect to %s: %s", log.warning("%s %s connect failed: %s",
self.device.id, self._last_connect_error) self._device.id, self._role, self.last_error)
return ok 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: def disconnect(self) -> None:
with self._lock: with self._reader.lock:
if self._client: self._reader.close()
try: with self._writer.lock:
self._client.close() self._writer.close()
except Exception:
pass
self._connected = False
self._client = None
@property @property
def connected(self) -> bool: 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: 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 Returns a flat list of bool ordered by slot then point (matching
the unified address scheme), or None on comms error. the unified address scheme), or None on comms error.
FC02 returns input bits starting at address 0. Because input modules Uses the read connection — never blocks write callers.
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() total = self.device.total_input_points()
if total == 0: if total == 0:
return [] return []
with self._lock: with self._reader.lock:
return self._fc02_locked(address=0, count=total) 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): for attempt in range(2):
if not self._connected: if not conn.connected:
if not self._connect_locked(): if not conn.connect():
return None return None
try: try:
rr = self._client.read_discrete_inputs( rr = conn.client.read_discrete_inputs(
address=address, count=count, address=address, count=count,
device_id=self.device.unit_id, device_id=self.device.unit_id,
) )
if rr.isError() or isinstance(rr, ExceptionResponse): if rr.isError() or isinstance(rr, ExceptionResponse):
log.warning("%s FC02 error: %s", self.device.id, rr) log.warning("%s FC02 error: %s", self.device.id, rr)
self._connected = False conn.connected = False
continue continue
return list(rr.bits[:count]) return list(rr.bits[:count])
except (ModbusException, ConnectionError, OSError) as exc: 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.device.id, attempt + 1, exc)
self._connected = False conn.connected = False
time.sleep(0.05) time.sleep(0.05)
return None 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: def read_registers(self, address: int, count: int) -> list[int] | None:
""" """
Read contiguous 16-bit input registers via FC04. Read contiguous 16-bit input registers via FC04.
Used for analog and temperature input modules whose signals live Uses the read connection — never blocks write callers.
in the register address space. Returns a list of raw int values
(065535), or None on comms error.
""" """
if count == 0: if count == 0:
return [] return []
with self._lock: with self._reader.lock:
return self._fc04_locked(address, count) 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): for attempt in range(2):
if not self._connected: if not conn.connected:
if not self._connect_locked(): if not conn.connect():
return None return None
try: try:
rr = self._client.read_input_registers( rr = conn.client.read_input_registers(
address=address, count=count, address=address, count=count,
device_id=self.device.unit_id, device_id=self.device.unit_id,
) )
if rr.isError() or isinstance(rr, ExceptionResponse): if rr.isError() or isinstance(rr, ExceptionResponse):
log.warning("%s FC04 error: %s", self.device.id, rr) log.warning("%s FC04 error: %s", self.device.id, rr)
self._connected = False conn.connected = False
continue continue
return list(rr.registers[:count]) return list(rr.registers[:count])
except (ModbusException, ConnectionError, OSError) as exc: except (ModbusException, ConnectionError, OSError) as exc:
log.warning("%s FC04 read error (attempt %d): %s", log.warning("%s FC04 read error (attempt %d): %s",
self.device.id, attempt + 1, exc) self.device.id, attempt + 1, exc)
self._connected = False conn.connected = False
time.sleep(0.05) time.sleep(0.05)
return None return None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Write digital outputs # Write digital outputs (uses write connection)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def write_output(self, address: int, value: bool) -> bool: def write_output(self, address: int, value: bool) -> bool:
""" """
Write a single coil via FC05. Write a single coil via FC05.
Address is the unified slot-order coil address (as stored in Uses the write connection — never blocked by poll thread reads.
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: with self._writer.lock:
return self._fc05_locked(address, value) 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): for attempt in range(2):
if not self._connected: if not conn.connected:
if not self._connect_locked(): if not conn.connect():
return False return False
try: try:
rr = self._client.write_coil( rr = conn.client.write_coil(
address=address, value=value, address=address, value=value,
device_id=self.device.unit_id, device_id=self.device.unit_id,
) )
if rr.isError() or isinstance(rr, ExceptionResponse): if rr.isError() or isinstance(rr, ExceptionResponse):
log.warning("%s FC05 error addr=%d: %s", log.warning("%s FC05 error addr=%d: %s",
self.device.id, address, rr) self.device.id, address, rr)
self._connected = False conn.connected = False
continue continue
log.debug("%s coil[%d] = %s", self.device.id, address, value) log.debug("%s coil[%d] = %s", self.device.id, address, value)
return True return True
except (ModbusException, ConnectionError, OSError) as exc: 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.device.id, attempt + 1, exc)
self._connected = False conn.connected = False
time.sleep(0.05) time.sleep(0.05)
return False return False
def write_outputs(self, address: int, values: list[bool]) -> bool: def write_outputs(self, address: int, values: list[bool]) -> bool:
"""Write multiple contiguous coils via FC15.""" """Write multiple contiguous coils via FC15. Uses write connection."""
with self._lock: with self._writer.lock:
for attempt in range(2): return self._fc15(self._writer, address, values)
if not self._connected:
if not self._connect_locked(): def _fc15(self, conn: _ModbusConn, address: int, values: list[bool]) -> bool:
return False for attempt in range(2):
try: if not conn.connected:
rr = self._client.write_coils( if not conn.connect():
address=address, values=values, return False
device_id=self.device.unit_id, try:
) rr = conn.client.write_coils(
if rr.isError() or isinstance(rr, ExceptionResponse): address=address, values=values,
log.warning("%s FC15 error addr=%d: %s", device_id=self.device.unit_id,
self.device.id, address, rr) )
self._connected = False if rr.isError() or isinstance(rr, ExceptionResponse):
continue log.warning("%s FC15 error addr=%d: %s",
return True self.device.id, address, rr)
except (ModbusException, ConnectionError, OSError) as exc: conn.connected = False
log.warning("%s write_coils error (attempt %d): %s", continue
self.device.id, attempt + 1, exc) return True
self._connected = False except (ModbusException, ConnectionError, OSError) as exc:
time.sleep(0.05) log.warning("%s FC15 write error (attempt %d): %s",
self.device.id, attempt + 1, exc)
conn.connected = False
time.sleep(0.05)
return False return False
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Write analog outputs # Write analog outputs (uses write connection)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def write_register(self, address: int, value: int) -> bool: def write_register(self, address: int, value: int) -> bool:
""" """
Write a single 16-bit holding register via FC06. Write a single 16-bit holding register via FC06.
Uses the write connection — never blocked by poll thread reads.
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: with self._writer.lock:
return self._fc06_locked(address, value) 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): for attempt in range(2):
if not self._connected: if not conn.connected:
if not self._connect_locked(): if not conn.connect():
return False return False
try: try:
rr = self._client.write_register( rr = conn.client.write_register(
address=address, value=value, address=address, value=value,
device_id=self.device.unit_id, device_id=self.device.unit_id,
) )
if rr.isError() or isinstance(rr, ExceptionResponse): if rr.isError() or isinstance(rr, ExceptionResponse):
log.warning("%s FC06 error addr=%d: %s", log.warning("%s FC06 error addr=%d: %s",
self.device.id, address, rr) self.device.id, address, rr)
self._connected = False conn.connected = False
continue continue
log.debug("%s reg[%d] = %d", self.device.id, address, value) log.debug("%s reg[%d] = %d", self.device.id, address, value)
return True return True
except (ModbusException, ConnectionError, OSError) as exc: except (ModbusException, ConnectionError, OSError) as exc:
log.warning("%s FC06 write error (attempt %d): %s", log.warning("%s FC06 write error (attempt %d): %s",
self.device.id, attempt + 1, exc) self.device.id, attempt + 1, exc)
self._connected = False conn.connected = False
time.sleep(0.05) time.sleep(0.05)
return False return False
def write_registers(self, address: int, values: list[int]) -> bool: def write_registers(self, address: int, values: list[int]) -> bool:
"""Write multiple contiguous 16-bit holding registers via FC16.""" """Write multiple contiguous 16-bit holding registers via FC16.
with self._lock: Uses write connection."""
for attempt in range(2): with self._writer.lock:
if not self._connected: return self._fc16(self._writer, address, values)
if not self._connect_locked():
return False def _fc16(self, conn: _ModbusConn, address: int, values: list[int]) -> bool:
try: for attempt in range(2):
rr = self._client.write_registers( if not conn.connected:
address=address, values=values, if not conn.connect():
device_id=self.device.unit_id, return False
) try:
if rr.isError() or isinstance(rr, ExceptionResponse): rr = conn.client.write_registers(
log.warning("%s FC16 error addr=%d: %s", address=address, values=values,
self.device.id, address, rr) device_id=self.device.unit_id,
self._connected = False )
continue if rr.isError() or isinstance(rr, ExceptionResponse):
return True log.warning("%s FC16 error addr=%d: %s",
except (ModbusException, ConnectionError, OSError) as exc: self.device.id, address, rr)
log.warning("%s FC16 write error (attempt %d): %s", conn.connected = False
self.device.id, attempt + 1, exc) continue
self._connected = False return True
time.sleep(0.05) 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 return False
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -387,9 +443,13 @@ class TerminatorIO:
"device_id": self.device.id, "device_id": self.device.id,
"host": self.device.host, "host": self.device.host,
"port": self.device.port, "port": self.device.port,
"connected": self._connected, "connected": self.connected,
"connect_attempts": self._connect_attempts, "reader_connected": self._reader.connected,
"last_error": self._last_connect_error or None, "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 Reads all input points from one EBC100 at poll_interval_ms, updates the
shared signal cache. Daemon thread — exits when the process does. shared signal cache. Daemon thread — exits when the process does.
Each poll cycle reads BOTH address spaces: Each poll cycle reads BOTH address spaces via the driver's read connection:
- FC02 (coil space): digital input signals list[bool] - FC02 (coil space): digital input signals -> list[bool]
- FC04 (register space): analog/temperature input signals list[int] - 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__( def __init__(
self, self,
driver: TerminatorIO, driver: TerminatorIO,
digital_signals: list["LogicalIO"], # digital input signals, sorted by modbus_address digital_signals: list["LogicalIO"],
analog_signals: list["LogicalIO"], # analog/temp input signals, sorted by modbus_address analog_signals: list["LogicalIO"],
cache: dict[str, SignalState], cache: dict[str, SignalState],
lock: threading.Lock, lock: threading.Lock,
) -> None: ) -> None:
@@ -443,7 +506,8 @@ class _PollThread(threading.Thread):
len(self._digital_signals), len(self._digital_signals),
len(self._analog_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_t0 = time.monotonic()
rate_polls = 0 rate_polls = 0
@@ -481,7 +545,7 @@ class _PollThread(threading.Thread):
updates: dict[str, SignalState] = {} updates: dict[str, SignalState] = {}
now = time.monotonic() now = time.monotonic()
# ── Digital inputs (FC02, coil space) ───────────────────────── # -- Digital inputs (FC02, coil space) -------------------------
if self._digital_signals: if self._digital_signals:
bits = self._driver.read_inputs() bits = self._driver.read_inputs()
if bits is None: if bits is None:
@@ -508,7 +572,7 @@ class _PollThread(threading.Thread):
self._driver.device.id, sig.name, self._driver.device.id, sig.name,
sig.modbus_address, len(bits)) sig.modbus_address, len(bits))
# ── Analog / temperature inputs (FC04, register space) ──────── # -- Analog / temperature inputs (FC04, register space) --------
if self._analog_signals: if self._analog_signals:
total_regs = self._driver.device.total_analog_input_channels() total_regs = self._driver.device.total_analog_input_channels()
regs = self._driver.read_registers(address=0, count=total_regs) 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: class IORegistry:
@@ -611,7 +675,7 @@ class IORegistry:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def start(self) -> None: 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: for p in self._pollers:
p.start() p.start()