Files
modbus_rtu_monitor/hub.py
T

728 lines
26 KiB
Python

"""Modbus RTU Monitor Hub for passive monitoring and active writes."""
from __future__ import annotations
import asyncio
import logging
import struct
from typing import TYPE_CHECKING
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers.event import async_call_later
from homeassistant.util.dt import utcnow
from .const import (
AVAILABILITY_TIMEOUT,
COIL_COUNT,
COIL_START_ADDRESS,
COORDINATOR_UPDATE_INTERVAL,
DOMAIN,
FRAME_PROCESSING_DELAY,
REGISTER_COUNT,
REGISTER_MONITOR_COUNT,
REGISTER_MONITOR_COUNT_2,
REGISTER_MONITOR_COUNT_3,
REGISTER_START_ADDRESS,
REGISTER_START_ADDRESS_2,
REGISTER_START_ADDRESS_3,
SETPOINT_REGISTER,
TEMP_HUMIDITY_REGISTER,
ModbusRTUFrame,
SlaveData,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from .coordinator import ModbusRTUMonitorCoordinator
_LOGGER = logging.getLogger(__name__)
class ModbusRTUDecoder:
"""Decode Modbus RTU frames."""
@staticmethod
def calculate_crc(data: bytes) -> int:
"""Calculate Modbus RTU CRC."""
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x0001:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc
@staticmethod
def build_write_register_frame(slave_id: int, register: int, value: int) -> bytes:
"""Build a Modbus RTU Write Single Register frame (function code 0x06).
Args:
slave_id: Modbus slave ID (1-247)
register: Register address (0-65535)
value: Register value to write (0-65535)
Returns:
Complete RTU frame with CRC
"""
# Function code 0x06 = Write Single Register
frame = bytearray()
frame.append(slave_id)
frame.append(0x06) # Function code
frame.extend(struct.pack(">H", register)) # Register address (big-endian)
frame.extend(struct.pack(">H", value)) # Register value (big-endian)
# Calculate and append CRC (little-endian)
crc = ModbusRTUDecoder.calculate_crc(bytes(frame))
frame.extend(struct.pack("<H", crc))
return bytes(frame)
@staticmethod
def decode_frame(data: bytes) -> ModbusRTUFrame | None:
"""Decode a Modbus RTU frame."""
if len(data) < 4:
return None
slave_id = data[0]
function_code = data[1]
frame_data = data[2:-2]
received_crc = struct.unpack("<H", data[-2:])[0]
calculated_crc = ModbusRTUDecoder.calculate_crc(data[:-2])
if received_crc != calculated_crc:
return None
decoded = ModbusRTUFrame(
slave_id=slave_id,
function_code=function_code,
data=frame_data,
is_request=False,
is_response=False,
)
# Detect request (function 1, Read Coils - with address and count)
if function_code == 1 and len(frame_data) == 4:
start_addr = struct.unpack(">H", frame_data[0:2])[0]
count = struct.unpack(">H", frame_data[2:4])[0]
decoded.is_request = True
decoded.start_address = start_addr
decoded.register_count = count
# Detect response (function 1, Read Coils - with byte count and coil values)
elif function_code == 1 and len(frame_data) > 1:
byte_count = frame_data[0]
if len(frame_data) >= byte_count + 1:
# Decode coil bitmap
coil_values = []
for byte_idx in range(byte_count):
byte_val = frame_data[byte_idx + 1]
# Each byte contains 8 coil states (LSB first)
for bit_idx in range(8):
coil_values.append(bool(byte_val & (1 << bit_idx)))
decoded.is_response = True
decoded.coil_values = coil_values
# Detect request (function 3, with address and count)
elif function_code == 3 and len(frame_data) == 4:
start_addr = struct.unpack(">H", frame_data[0:2])[0]
count = struct.unpack(">H", frame_data[2:4])[0]
decoded.is_request = True
decoded.start_address = start_addr
decoded.register_count = count
# Detect response (function 3, with byte count and values)
elif function_code == 3 and len(frame_data) > 1:
byte_count = frame_data[0]
if len(frame_data) >= byte_count + 1:
values = []
for i in range(0, byte_count, 2):
if i + 1 < byte_count:
val = struct.unpack(">H", frame_data[i + 1 : i + 3])[0]
values.append(val)
decoded.is_response = True
decoded.values = values
return decoded
class ModbusRTUMonitorHub:
"""Hub for passive Modbus RTU monitoring and active writes."""
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, host: str, port: int
) -> None:
"""Initialize the hub."""
self.hass = hass
self.config_entry = config_entry
self.host = host
self.port = port
# Asyncio TCP connection
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._monitor_task: asyncio.Task | None = None
# Coordination lock for write operations
self._write_lock = asyncio.Lock()
# Slave discovery and state
self.discovered_slaves: dict[int, SlaveData] = {}
self._pending_requests: dict[int | str, dict] = {}
# Frame decoder
self._decoder = ModbusRTUDecoder()
# Coordinator reference (set after initialization)
self.coordinator: ModbusRTUMonitorCoordinator | None = None
# Availability check task
self._availability_task: asyncio.Task | None = None
# Throttling for coordinator updates
self._last_coordinator_update = utcnow() - COORDINATOR_UPDATE_INTERVAL
def _should_update_coordinator(self) -> bool:
"""Check if enough time has passed to update the coordinator."""
return (utcnow() - self._last_coordinator_update) >= COORDINATOR_UPDATE_INTERVAL
def _update_coordinator_throttled(self) -> None:
"""Update coordinator with throttling to prevent overwhelming HA."""
if self.coordinator and self._should_update_coordinator():
self.coordinator.async_set_updated_data(self.discovered_slaves)
self._last_coordinator_update = utcnow()
async def async_setup(self) -> bool:
"""Set up the hub and start monitoring."""
try:
await self._connect()
self._monitor_task = asyncio.create_task(self._monitor_loop())
self._availability_task = asyncio.create_task(
self._availability_check_loop()
)
return True
except Exception as ex:
raise ConfigEntryNotReady(
f"Failed to connect to {self.host}:{self.port}"
) from ex
async def _connect(self) -> None:
"""Establish TCP connection."""
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self.host, self.port),
timeout=10.0,
)
_LOGGER.info(
"Connected to Modbus RTU gateway at %s:%s", self.host, self.port
)
async def _reconnect(self) -> None:
"""Reconnect to Modbus gateway after connection loss."""
# Close existing connection
if self._writer:
self._writer.close()
await self._writer.wait_closed()
# Wait before reconnecting (30 seconds to prevent DDOS during server outages)
await asyncio.sleep(30)
# Attempt reconnection
try:
await self._connect()
_LOGGER.info("Reconnected to Modbus gateway")
except Exception as ex:
_LOGGER.error("Reconnection failed: %s", ex)
# Will retry on next loop iteration
async def _monitor_loop(self) -> None:
"""Main monitoring loop for passive frame reception."""
buffer = bytearray()
while True:
try:
# Read with timeout to allow graceful shutdown
data = await asyncio.wait_for(self._reader.read(1024), timeout=1.0)
if not data:
# Connection closed
_LOGGER.warning("Connection closed, attempting reconnect")
await self._reconnect()
continue
buffer.extend(data)
# Process frames from buffer (limit iterations to prevent blocking)
max_iterations = 100
iterations = 0
while len(buffer) >= 4 and iterations < max_iterations:
frame_found = False
# Try different frame lengths
for frame_len in range(4, min(len(buffer) + 1, 256)):
potential_frame = bytes(buffer[:frame_len])
decoded = self._decoder.decode_frame(potential_frame)
if decoded:
await self._handle_frame(decoded)
buffer = buffer[frame_len:]
frame_found = True
break
if not frame_found:
# Invalid data, discard first byte
buffer = buffer[1:]
iterations += 1
# If we hit the iteration limit, yield to event loop
if iterations >= max_iterations and len(buffer) >= 4:
await asyncio.sleep(0) # Yield control to event loop
# Rate limiting: sleep after processing to avoid overwhelming HA
if iterations > 0:
await asyncio.sleep(FRAME_PROCESSING_DELAY)
except asyncio.TimeoutError:
# Normal timeout, continue
continue
except asyncio.CancelledError:
break
except Exception as ex:
_LOGGER.error("Error in monitor loop: %s", ex)
await asyncio.sleep(5)
async def _handle_frame(self, frame: ModbusRTUFrame) -> None:
"""Handle a decoded Modbus RTU frame."""
slave_id = frame.slave_id
# Detect Read Coils request (function 01)
if (
frame.is_request
and frame.function_code == 1
and frame.start_address == COIL_START_ADDRESS
and frame.register_count == COIL_COUNT
):
self._pending_requests[f"{slave_id}_coils"] = {
"timestamp": utcnow(),
"slave_id": slave_id,
"type": "coils",
}
# Handle Read Coils response
elif (
frame.is_response
and frame.function_code == 1
and f"{slave_id}_coils" in self._pending_requests
and frame.coil_values
):
# Ensure slave exists
if slave_id not in self.discovered_slaves:
_LOGGER.info("Discovered new slave via coils: %s", slave_id)
self.discovered_slaves[slave_id] = SlaveData(
slave_id=slave_id,
temperature=None,
humidity=None,
last_seen=utcnow(),
available=False,
)
# Update coil data (limit to COIL_COUNT coils)
slave_data = self.discovered_slaves[slave_id]
slave_data.coils = frame.coil_values[:COIL_COUNT]
slave_data.last_seen = utcnow()
slave_data.available = True
# Clear pending request
del self._pending_requests[f"{slave_id}_coils"]
_LOGGER.debug(
"Slave %s: Updated %d coils",
slave_id,
len(slave_data.coils),
)
# Notify coordinator (throttled)
self._update_coordinator_throttled()
# Detect Read Holding Registers request (function 03) for monitored registers
elif (
frame.is_request
and frame.function_code == 3
and frame.start_address == REGISTER_START_ADDRESS
and frame.register_count == REGISTER_MONITOR_COUNT
):
self._pending_requests[f"{slave_id}_registers"] = {
"timestamp": utcnow(),
"slave_id": slave_id,
"type": "registers",
"start_address": frame.start_address,
}
# Handle Read Holding Registers response for monitored registers
elif (
frame.is_response
and frame.function_code == 3
and f"{slave_id}_registers" in self._pending_requests
and frame.values
):
# Ensure slave exists
if slave_id not in self.discovered_slaves:
_LOGGER.info("Discovered new slave via registers: %s", slave_id)
self.discovered_slaves[slave_id] = SlaveData(
slave_id=slave_id,
temperature=None,
humidity=None,
last_seen=utcnow(),
available=False,
)
# Get start address from pending request
start_addr = self._pending_requests[f"{slave_id}_registers"]["start_address"]
# Update register data - convert unsigned to signed int16
slave_data = self.discovered_slaves[slave_id]
if slave_data.registers is None:
slave_data.registers = {}
for idx, value in enumerate(frame.values):
register_addr = start_addr + idx
# Convert unsigned 16-bit to signed int16
if value > 32767:
signed_value = value - 65536
else:
signed_value = value
slave_data.registers[register_addr] = signed_value
slave_data.last_seen = utcnow()
slave_data.available = True
# Clear pending request
del self._pending_requests[f"{slave_id}_registers"]
_LOGGER.debug(
"Slave %s: Updated %d registers starting at %d",
slave_id,
len(frame.values),
start_addr,
)
# Notify coordinator (throttled)
self._update_coordinator_throttled()
# Detect Read Holding Registers request (function 03) for second monitored register range
elif (
frame.is_request
and frame.function_code == 3
and frame.start_address == REGISTER_START_ADDRESS_2
and frame.register_count == REGISTER_MONITOR_COUNT_2
):
self._pending_requests[f"{slave_id}_registers2"] = {
"timestamp": utcnow(),
"slave_id": slave_id,
"type": "registers2",
"start_address": frame.start_address,
}
# Handle Read Holding Registers response for second monitored register range
elif (
frame.is_response
and frame.function_code == 3
and f"{slave_id}_registers2" in self._pending_requests
and frame.values
):
# Ensure slave exists
if slave_id not in self.discovered_slaves:
_LOGGER.info("Discovered new slave via registers2: %s", slave_id)
self.discovered_slaves[slave_id] = SlaveData(
slave_id=slave_id,
temperature=None,
humidity=None,
last_seen=utcnow(),
available=False,
)
# Get start address from pending request
start_addr = self._pending_requests[f"{slave_id}_registers2"]["start_address"]
# Update register data - convert unsigned to signed int16
slave_data = self.discovered_slaves[slave_id]
if slave_data.registers is None:
slave_data.registers = {}
for idx, value in enumerate(frame.values):
register_addr = start_addr + idx
# Convert unsigned 16-bit to signed int16
if value > 32767:
signed_value = value - 65536
else:
signed_value = value
slave_data.registers[register_addr] = signed_value
slave_data.last_seen = utcnow()
slave_data.available = True
# Clear pending request
del self._pending_requests[f"{slave_id}_registers2"]
_LOGGER.debug(
"Slave %s: Updated %d registers (range 2) starting at %d",
slave_id,
len(frame.values),
start_addr,
)
# Notify coordinator (throttled)
self._update_coordinator_throttled()
# Detect Read Holding Registers request (function 03) for third monitored register range
elif (
frame.is_request
and frame.function_code == 3
and frame.start_address == REGISTER_START_ADDRESS_3
and frame.register_count == REGISTER_MONITOR_COUNT_3
):
self._pending_requests[f"{slave_id}_registers3"] = {
"timestamp": utcnow(),
"slave_id": slave_id,
"type": "registers3",
"start_address": frame.start_address,
}
# Handle Read Holding Registers response for third monitored register range
elif (
frame.is_response
and frame.function_code == 3
and f"{slave_id}_registers3" in self._pending_requests
and frame.values
):
# Ensure slave exists
if slave_id not in self.discovered_slaves:
_LOGGER.info("Discovered new slave via registers3: %s", slave_id)
self.discovered_slaves[slave_id] = SlaveData(
slave_id=slave_id,
temperature=None,
humidity=None,
last_seen=utcnow(),
available=False,
)
# Get start address from pending request
start_addr = self._pending_requests[f"{slave_id}_registers3"]["start_address"]
# Update register data - convert unsigned to signed int16
slave_data = self.discovered_slaves[slave_id]
if slave_data.registers is None:
slave_data.registers = {}
for idx, value in enumerate(frame.values):
register_addr = start_addr + idx
# Convert unsigned 16-bit to signed int16
if value > 32767:
signed_value = value - 65536
else:
signed_value = value
slave_data.registers[register_addr] = signed_value
slave_data.last_seen = utcnow()
slave_data.available = True
# Clear pending request
del self._pending_requests[f"{slave_id}_registers3"]
_LOGGER.debug(
"Slave %s: Updated %d registers (range 3) starting at %d",
slave_id,
len(frame.values),
start_addr,
)
# Notify coordinator (throttled)
self._update_coordinator_throttled()
# Detect discovery: request to register 0x83 with 4 registers
elif (
frame.is_request
and frame.start_address == TEMP_HUMIDITY_REGISTER
and frame.register_count == REGISTER_COUNT
):
self._pending_requests[slave_id] = {
"timestamp": utcnow(),
"slave_id": slave_id,
}
# Auto-discover new slave
if slave_id not in self.discovered_slaves:
_LOGGER.info("Discovered new slave: %s", slave_id)
self.discovered_slaves[slave_id] = SlaveData(
slave_id=slave_id,
temperature=None,
humidity=None,
last_seen=utcnow(),
available=False,
)
# Trigger entity creation (throttled)
self._update_coordinator_throttled()
# Handle response with temperature/humidity data
elif (
frame.is_response
and slave_id in self._pending_requests
and frame.values
and len(frame.values) >= 4
):
# Extract temperature (index 2) and humidity (index 3)
temp_raw = frame.values[2]
humidity_raw = frame.values[3]
# Scale values (divide by 10)
temperature = temp_raw / 10.0
humidity = humidity_raw / 10.0
# Update slave data
slave_data = self.discovered_slaves[slave_id]
slave_data.temperature = temperature
slave_data.humidity = humidity
slave_data.last_seen = utcnow()
slave_data.available = True
# Clear pending request
del self._pending_requests[slave_id]
_LOGGER.debug(
"Slave %s: Temperature=%.1f°C, Humidity=%.1f%%",
slave_id,
temperature,
humidity,
)
# Notify coordinator (throttled)
self._update_coordinator_throttled()
async def _availability_check_loop(self) -> None:
"""Periodically check slave availability."""
while True:
try:
await asyncio.sleep(60) # Check every minute
self._check_slave_availability()
except asyncio.CancelledError:
break
except Exception as ex:
_LOGGER.error("Error in availability check loop: %s", ex)
def _check_slave_availability(self) -> None:
"""Mark slaves unavailable if no data received recently."""
now = utcnow()
updated = False
for slave_data in self.discovered_slaves.values():
if (now - slave_data.last_seen) > AVAILABILITY_TIMEOUT:
if slave_data.available:
slave_data.available = False
updated = True
_LOGGER.warning("Slave %s marked unavailable", slave_data.slave_id)
if updated:
self._update_coordinator_throttled()
async def async_write_setpoint(
self, slave_id: int, temperature: float
) -> None:
"""Write temperature setpoint to slave using RTU protocol over existing connection."""
async with self._write_lock:
# Ensure writer is available
if not self._writer:
raise HomeAssistantError(
"No active connection to Modbus gateway - cannot write setpoint"
)
# Scale temperature value (multiply by 10)
value = int(temperature * 10)
# Ensure value fits in uint16
if not 0 <= value <= 65535:
raise HomeAssistantError(
f"Setpoint value {value} out of valid range (0-65535)"
)
_LOGGER.info(
"Writing setpoint via RTU - slave_id=%s, temp=%.1f°C, scaled_value=%d, register=%s",
slave_id,
temperature,
value,
SETPOINT_REGISTER,
)
try:
# Build Modbus RTU Write Single Register frame
frame = self._decoder.build_write_register_frame(
slave_id=slave_id,
register=SETPOINT_REGISTER,
value=value,
)
_LOGGER.debug(
"Sending RTU frame: %s (length=%d)",
frame.hex(" "),
len(frame),
)
# Send frame via existing TCP connection
self._writer.write(frame)
await self._writer.drain()
_LOGGER.info(
"Successfully sent setpoint %.1f°C (value=%d) to slave %s register %s",
temperature,
value,
slave_id,
SETPOINT_REGISTER,
)
# Note: We don't wait for response here as the passive monitoring
# loop will receive and process it. The Write Single Register
# response echoes the request if successful, or returns an error code.
except OSError as ex:
_LOGGER.exception(
"Connection error writing setpoint to slave %s: %s",
slave_id,
ex,
)
raise HomeAssistantError(
f"Connection error writing setpoint to slave {slave_id}"
) from ex
except Exception as ex:
_LOGGER.exception(
"Unexpected error writing setpoint to slave %s: %s",
slave_id,
ex,
)
raise HomeAssistantError(
f"Unexpected error writing setpoint to slave {slave_id}"
) from ex
async def async_close(self) -> None:
"""Close hub and cleanup resources."""
if self._monitor_task:
self._monitor_task.cancel()
try:
await self._monitor_task
except asyncio.CancelledError:
pass
if self._availability_task:
self._availability_task.cancel()
try:
await self._availability_task
except asyncio.CancelledError:
pass
if self._writer:
self._writer.close()
await self._writer.wait_closed()
_LOGGER.info("Modbus RTU Monitor hub closed")