"""Persistentní Modbus TCP klient na sdílené Waveshare / RS485 bráně (jedno spojení + lock).""" from __future__ import annotations import asyncio import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pymodbus.client import AsyncModbusTcpClient logger = logging.getLogger(__name__) class ModbusBatch: """Více read/write pod jedním držením locku (žádný jiný task na stejném klientovi mezi nimi).""" def __init__(self, owner: PersistentModbusClient) -> None: self._o = owner async def read_register(self, address: int) -> int: return await self._o._read_register_locked(address) async def read_register_signed(self, address: int) -> int: raw = await self.read_register(address) return raw - 65536 if raw > 32767 else raw async def write_register(self, address: int, value: int) -> bool: return await self._o._write_register_locked(address, value) async def write_registers(self, address: int, values: list[int]) -> bool: return await self._o._write_registers_locked(address, values) class PersistentModbusClient: """ Jedno persistentní TCP spojení na převodník. Serializuje všechny požadavky přes asyncio.Lock(). Automaticky reconnectuje při výpadku. """ def __init__(self, host: str, port: int, device_id: int = 1) -> None: self.host = host self.port = port self.device_id = device_id self._client: AsyncModbusTcpClient | None = None self._lock = asyncio.Lock() async def _ensure_connected(self) -> None: if self._client is not None and self._client.connected: return if self._client is not None: self._client.close() self._client = None logger.info("Modbus connecting %s:%s dev=%s", self.host, self.port, self.device_id) self._client = AsyncModbusTcpClient( self.host, port=self.port, timeout=5, retries=2, ) await self._client.connect() if not self._client.connected: self._client.close() self._client = None raise ConnectionError(f"Cannot connect Modbus {self.host}:{self.port}") logger.info("Modbus connected %s:%s", self.host, self.port) async def _read_register_locked(self, address: int) -> int: if self._client is None or not self._client.connected: await self._ensure_connected() assert self._client is not None try: r = await self._client.read_holding_registers( address, count=1, device_id=self.device_id ) if r.isError() or not getattr(r, "registers", None): raise OSError(f"Read error 0x{address:04X}: {r!r}") return int(r.registers[0]) except Exception as e: logger.warning("Modbus read 0x%04X failed: %s", address, e) self._client.close() self._client = None raise async def _write_registers_locked(self, address: int, values: list[int]) -> bool: if self._client is None or not self._client.connected: await self._ensure_connected() assert self._client is not None try: clamped = [max(0, min(65535, int(v))) for v in values] r = await self._client.write_registers( address, clamped, device_id=self.device_id ) if r.isError(): raise OSError(f"Write error 0x{address:04X}={clamped}: {r!r}") return True except Exception as e: logger.warning( "Modbus write_registers 0x%04X failed: %s", address, e ) self._client.close() self._client = None raise async def _write_register_locked(self, address: int, value: int) -> bool: if self._client is None or not self._client.connected: await self._ensure_connected() assert self._client is not None try: v = max(0, min(65535, int(value))) r = await self._client.write_register(address, v, device_id=self.device_id) if r.isError(): raise OSError(f"Write error 0x{address:04X}={v}: {r!r}") return True except Exception as e: logger.warning("Modbus write 0x%04X=%s failed: %s", address, value, e) self._client.close() self._client = None raise async def read_register(self, address: int) -> int: async with self._lock: await self._ensure_connected() return await self._read_register_locked(address) async def read_register_signed(self, address: int) -> int: raw = await self.read_register(address) return raw - 65536 if raw > 32767 else raw async def write_register(self, address: int, value: int) -> bool: async with self._lock: await self._ensure_connected() return await self._write_register_locked(address, value) async def write_registers(self, address: int, values: list[int]) -> bool: """FC 0x10 – povinné pro Deye registry 60–499 (jeden i více registrů).""" async with self._lock: await self._ensure_connected() return await self._write_registers_locked(address, values) @asynccontextmanager async def batch(self) -> AsyncIterator[ModbusBatch]: """Drží lock pro více po sobě jdoucích operací (telemetrie vs. control na stejné bráně).""" async with self._lock: await self._ensure_connected() yield ModbusBatch(self) def close(self) -> None: if self._client is not None: self._client.close() self._client = None _clients: dict[str, PersistentModbusClient] = {} _registry_lock = asyncio.Lock() async def get_modbus_client( host: str, port: int, device_id: int = 1 ) -> PersistentModbusClient: key = f"{host}:{port}:{device_id}" async with _registry_lock: if key not in _clients: _clients[key] = PersistentModbusClient(host, port, device_id) return _clients[key]