Files
ems/backend/services/modbus_client.py
Dusan Vojacek 9f4126946d second version
2026-04-03 14:23:16 +02:00

167 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 60499 (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]