mirror of
https://github.com/home-assistant/core.git
synced 2026-09-13 04:01:03 +01:00
Co-authored-by: Paulus Schoutsen <balloob@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
172 lines
5.6 KiB
Python
172 lines
5.6 KiB
Python
"""Hand out Modbus units over connections shared between integrations."""
|
|
|
|
from collections.abc import AsyncIterator, Callable, Coroutine
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass, field
|
|
import logging
|
|
from typing import Any
|
|
|
|
from modbus_connection import (
|
|
ModbusSerialParams,
|
|
ModbusTcpParams,
|
|
ModbusTlsParams,
|
|
ModbusUdpParams,
|
|
ModbusUnit,
|
|
)
|
|
from modbus_connection.tmodbus import ModbusConnection
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.util.hass_dict import HassKey
|
|
|
|
from .const import DOMAIN
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
type ModbusParams = (
|
|
ModbusTcpParams | ModbusUdpParams | ModbusTlsParams | ModbusSerialParams
|
|
)
|
|
type ModbusEndpoint = tuple[str, str, int] | tuple[str, str]
|
|
|
|
DATA_MODBUS_CONNECTIONS: HassKey[dict[ModbusEndpoint, _SharedConnection]] = HassKey(
|
|
f"{DOMAIN}_connections"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _SharedConnection:
|
|
"""A connection and the units held on it."""
|
|
|
|
params: ModbusParams
|
|
connection: ModbusConnection
|
|
units: dict[str, set[int]] = field(default_factory=dict)
|
|
"""The unit ids each config entry holds, keyed by entry id."""
|
|
transient: int = 0
|
|
"""Holds with no config entry behind them, taken by a config flow."""
|
|
|
|
@property
|
|
def consumers(self) -> int:
|
|
"""How many holds are on this connection."""
|
|
return sum(len(held) for held in self.units.values()) + self.transient
|
|
|
|
|
|
@dataclass(frozen=True, kw_only=True)
|
|
class ModbusConnectionInfo:
|
|
"""A connection the integration is keeping open, and who is using it."""
|
|
|
|
endpoint: ModbusEndpoint
|
|
connected: bool
|
|
units: dict[str, list[int]]
|
|
"""The unit ids each config entry holds, keyed by entry id."""
|
|
|
|
|
|
@callback
|
|
def _async_acquire(
|
|
hass: HomeAssistant,
|
|
params: ModbusParams,
|
|
entry_id: str | None,
|
|
unit_id: int,
|
|
) -> tuple[ModbusConnection, Callable[[], Coroutine[Any, Any, None]]]:
|
|
"""Take a hold on the connection these credentials describe.
|
|
|
|
A hold with no ``entry_id`` behind it is a config flow's, which keeps the
|
|
connection up without belonging to anything that could be shown as using
|
|
it.
|
|
|
|
Raises `HomeAssistantError` if the device is already in use over different
|
|
link settings, which cannot both be honoured on one connection.
|
|
"""
|
|
endpoint = params.endpoint
|
|
connections = hass.data.setdefault(DATA_MODBUS_CONNECTIONS, {})
|
|
if (shared := connections.get(endpoint)) is None:
|
|
shared = connections[endpoint] = _SharedConnection(
|
|
params, ModbusConnection(params)
|
|
)
|
|
elif shared.params != params:
|
|
raise HomeAssistantError(
|
|
f"Modbus device {endpoint} is already in use with different link "
|
|
f"settings: {shared.params} against {params}"
|
|
)
|
|
|
|
if entry_id is None:
|
|
shared.transient += 1
|
|
else:
|
|
shared.units.setdefault(entry_id, set()).add(unit_id)
|
|
|
|
async def release() -> None:
|
|
"""Give up this hold, closing behind the last one."""
|
|
if entry_id is None:
|
|
shared.transient -= 1
|
|
elif (held := shared.units.get(entry_id)) is not None:
|
|
held.discard(unit_id)
|
|
if not held:
|
|
del shared.units[entry_id]
|
|
if shared.consumers or connections.get(endpoint) is not shared:
|
|
return
|
|
del connections[endpoint]
|
|
_LOGGER.debug("Closing the Modbus connection to %s", endpoint)
|
|
await shared.connection.close()
|
|
|
|
return shared.connection, release
|
|
|
|
|
|
@callback
|
|
def async_get_unit(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
params: ModbusParams,
|
|
unit_id: int,
|
|
) -> ModbusUnit:
|
|
"""Return a unit on the connection these credentials describe.
|
|
|
|
Consumers of one device share a connection, so their requests serialize
|
|
behind its lock. It is closed when the last config entry holding a unit on
|
|
it unloads.
|
|
|
|
Raises `HomeAssistantError` if the device is already in use over different
|
|
link settings, which cannot both be honoured on one connection.
|
|
"""
|
|
connection, release = _async_acquire(hass, params, entry.entry_id, unit_id)
|
|
entry.async_on_unload(release)
|
|
return connection.for_unit(unit_id)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def async_get_temporary_unit(
|
|
hass: HomeAssistant,
|
|
params: ModbusParams,
|
|
unit_id: int,
|
|
) -> AsyncIterator[ModbusUnit]:
|
|
"""Hold a unit on the connection these credentials describe for the context.
|
|
|
|
For config flows, which have no config entry yet to tie a hold to. A
|
|
connection already held by a config entry is shared and stays up; one
|
|
opened here is closed on exit.
|
|
|
|
Raises `HomeAssistantError` if the device is already in use over different
|
|
link settings, which cannot both be honoured on one connection.
|
|
"""
|
|
connection, release = _async_acquire(hass, params, None, unit_id)
|
|
try:
|
|
yield connection.for_unit(unit_id)
|
|
finally:
|
|
await release()
|
|
|
|
|
|
@callback
|
|
def async_get_connection_info(hass: HomeAssistant) -> list[ModbusConnectionInfo]:
|
|
"""Return the connections the integration is keeping open.
|
|
|
|
One entry per physical device, naming the config entries holding units on
|
|
it. A device several integrations share appears once, with all of them.
|
|
"""
|
|
return [
|
|
ModbusConnectionInfo(
|
|
endpoint=endpoint,
|
|
connected=shared.connection.connected,
|
|
units={entry_id: sorted(held) for entry_id, held in shared.units.items()},
|
|
)
|
|
for endpoint, shared in hass.data.get(DATA_MODBUS_CONNECTIONS, {}).items()
|
|
]
|