diff --git a/.strict-typing b/.strict-typing index 66075b93743b..d37067a37220 100644 --- a/.strict-typing +++ b/.strict-typing @@ -378,6 +378,7 @@ homeassistant.components.min_max.* homeassistant.components.minecraft_server.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* +homeassistant.components.modbus_connection.* homeassistant.components.modem_callerid.* homeassistant.components.mold_indicator.* homeassistant.components.monzo.* diff --git a/homeassistant/components/modbus_connection/__init__.py b/homeassistant/components/modbus_connection/__init__.py new file mode 100644 index 000000000000..752771676a27 --- /dev/null +++ b/homeassistant/components/modbus_connection/__init__.py @@ -0,0 +1,94 @@ +"""The Modbus Connection integration.""" + +from collections.abc import Mapping +from typing import Any, cast + +from modbus_connection import ModbusConnection, ModbusConnectionError, ModbusUnit +from modbus_connection.tmodbus import connect_serial, connect_tcp + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + CONF_BAUDRATE, + CONF_BYTESIZE, + CONF_PARITY, + CONF_STOPBITS, + CONNECTION_SERIAL, +) +from .exceptions import ConnectionNotReady + +__all__ = ["ConnectionNotReady", "async_get_unit"] + +type ModbusConnectionConfigEntry = ConfigEntry[ModbusConnection] + + +async def _async_open(data: Mapping[str, Any]) -> ModbusConnection: + """Open the connection described by ``data`` (transport parameters). + + Shared by config-entry setup and the config flow's validation; the caller + owns the returned connection and closes it. + """ + if data[CONF_TYPE] == CONNECTION_SERIAL: + return await connect_serial( + data[CONF_DEVICE], + baudrate=data[CONF_BAUDRATE], + bytesize=data[CONF_BYTESIZE], + parity=data[CONF_PARITY], + stopbits=data[CONF_STOPBITS], + ) + return await connect_tcp(data[CONF_HOST], port=data[CONF_PORT]) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ModbusConnectionConfigEntry +) -> bool: + """Set up a Modbus connection from a config entry.""" + try: + connection = await _async_open(entry.data) + except ModbusConnectionError as err: + raise ConfigEntryNotReady(f"Could not open Modbus connection: {err}") from err + + entry.runtime_data = connection + + # The connection is transient and does not self-reconnect: on a drop, reload + # this entry. HA's ConfigEntryNotReady retry is the reconnect backoff. + entry.async_on_unload( + connection.on_connection_lost( + lambda: hass.config_entries.async_schedule_reload(entry.entry_id) + ) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: ModbusConnectionConfigEntry +) -> bool: + """Unload a config entry and close the owned connection.""" + await entry.runtime_data.close() + return True + + +@callback +def async_get_unit( + hass: HomeAssistant, connection_entry_id: str, unit_id: int +) -> ModbusUnit: + """Return a Modbus unit on a shared connection. + + Consumer integrations call this to borrow a ``ModbusUnit`` bound to their + unit ID; the ``ModbusConnection`` itself never leaves this integration. + + Raises ``ConnectionNotReady`` if the connection entry is missing or not + loaded. It is a ``ConfigEntryNotReady``, so a consumer can let it propagate + from its own ``async_setup_entry`` to get Home Assistant's setup retry. + """ + entry = cast( + "ModbusConnectionConfigEntry | None", + hass.config_entries.async_get_entry(connection_entry_id), + ) + if entry is None or entry.state is not ConfigEntryState.LOADED: + raise ConnectionNotReady(connection_entry_id) + return entry.runtime_data.for_unit(unit_id) diff --git a/homeassistant/components/modbus_connection/config_flow.py b/homeassistant/components/modbus_connection/config_flow.py new file mode 100644 index 000000000000..c1901f88fd17 --- /dev/null +++ b/homeassistant/components/modbus_connection/config_flow.py @@ -0,0 +1,123 @@ +"""Config flow for the Modbus Connection integration.""" + +from typing import Any, override + +from modbus_connection import ModbusError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.selector import SerialPortSelector + +from . import _async_open +from .const import ( + CONF_BAUDRATE, + CONF_BYTESIZE, + CONF_PARITY, + CONF_STOPBITS, + CONNECTION_SERIAL, + CONNECTION_TCP, + DEFAULT_BAUDRATE, + DEFAULT_BYTESIZE, + DEFAULT_PARITY, + DEFAULT_PORT, + DEFAULT_STOPBITS, + DOMAIN, +) + +STEP_NETWORK = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + } +) + +# SerialPortSelector lists local serial ports and network serial proxies. +STEP_SERIAL = vol.Schema( + { + vol.Required(CONF_DEVICE): SerialPortSelector(), + vol.Required(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): vol.Coerce(int), + vol.Required(CONF_PARITY, default=DEFAULT_PARITY): vol.In(["N", "E", "O"]), + vol.Required(CONF_STOPBITS, default=DEFAULT_STOPBITS): vol.In([1, 2]), + vol.Required(CONF_BYTESIZE, default=DEFAULT_BYTESIZE): vol.In([7, 8]), + } +) + + +class ModbusConnectionConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Modbus Connection.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Let the user choose the transport.""" + return self.async_show_menu( + step_id="user", + menu_options=["network", "serial"], + ) + + async def async_step_network( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure a Modbus TCP / RTU-over-TCP connection.""" + errors: dict[str, str] = {} + if user_input is not None: + data = {CONF_TYPE: CONNECTION_TCP, **user_input} + self._abort_if_configured(data) + if not (errors := await self._async_validate(data)): + return self.async_create_entry( + title=f"{data[CONF_HOST]}:{data[CONF_PORT]}", data=data + ) + return self.async_show_form( + step_id="network", data_schema=STEP_NETWORK, errors=errors + ) + + async def async_step_serial( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure a Modbus serial (RTU) connection, incl. network serial proxies.""" + errors: dict[str, str] = {} + if user_input is not None: + data = {CONF_TYPE: CONNECTION_SERIAL, **user_input} + self._abort_if_configured(data) + if not (errors := await self._async_validate(data)): + return self.async_create_entry(title=data[CONF_DEVICE], data=data) + return self.async_show_form( + step_id="serial", data_schema=STEP_SERIAL, errors=errors + ) + + def _abort_if_configured(self, data: dict[str, Any]) -> None: + """Abort if this exact link is already configured. + + A Modbus endpoint has no hardware identity to use as a unique ID, so we + dedupe by connection parameters. We check *before* opening the + connection: most Modbus devices reject a second client, so probing one + that is already in use would fail. + """ + for entry in self._async_current_entries(include_ignore=True): + if entry.data[CONF_TYPE] != data[CONF_TYPE]: + continue + if data[CONF_TYPE] == CONNECTION_SERIAL: + configured = entry.data[CONF_DEVICE] == data[CONF_DEVICE] + else: + configured = ( + entry.data[CONF_HOST] == data[CONF_HOST] + and entry.data[CONF_PORT] == data[CONF_PORT] + ) + if configured: + raise AbortFlow("already_configured") + + async def _async_validate(self, data: dict[str, Any]) -> dict[str, str]: + """Validate by actually opening the connection; return form errors.""" + try: + connection = await _async_open(data) + except ModbusError: + if data[CONF_TYPE] == CONNECTION_SERIAL: + return {"base": "cannot_open_serial_port"} + return {"base": "cannot_connect"} + await connection.close() + return {} diff --git a/homeassistant/components/modbus_connection/const.py b/homeassistant/components/modbus_connection/const.py new file mode 100644 index 000000000000..d826b80cf5a2 --- /dev/null +++ b/homeassistant/components/modbus_connection/const.py @@ -0,0 +1,21 @@ +"""Constants for the Modbus Connection integration.""" + +from typing import Final + +DOMAIN: Final = "modbus_connection" + +# Transport selection (stored under homeassistant.const.CONF_TYPE). +CONNECTION_TCP: Final = "tcp" +CONNECTION_SERIAL: Final = "serial" + +# Serial-only options. +CONF_BAUDRATE: Final = "baudrate" +CONF_BYTESIZE: Final = "bytesize" +CONF_PARITY: Final = "parity" +CONF_STOPBITS: Final = "stopbits" + +DEFAULT_PORT: Final = 502 +DEFAULT_BAUDRATE: Final = 9600 +DEFAULT_BYTESIZE: Final = 8 +DEFAULT_PARITY: Final = "N" +DEFAULT_STOPBITS: Final = 1 diff --git a/homeassistant/components/modbus_connection/exceptions.py b/homeassistant/components/modbus_connection/exceptions.py new file mode 100644 index 000000000000..5c1ee68134b9 --- /dev/null +++ b/homeassistant/components/modbus_connection/exceptions.py @@ -0,0 +1,25 @@ +"""Exceptions for the Modbus Connection integration.""" + +from modbus_connection import ModbusError + +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + + +class ConnectionNotReady(ConfigEntryNotReady, ModbusError): + """The shared Modbus connection is missing or not loaded. + + Raised by ``async_get_unit``. It is a ``ConfigEntryNotReady`` so a consumer + integration can let it propagate from its own ``async_setup_entry`` to get + Home Assistant's setup-retry behaviour, and a ``ModbusError`` so it is also + catchable with the library's error type. + """ + + def __init__(self, connection_entry_id: str) -> None: + """Initialize the error.""" + super().__init__( + translation_domain=DOMAIN, + translation_key="connection_not_ready", + ) + self.connection_entry_id = connection_entry_id diff --git a/homeassistant/components/modbus_connection/manifest.json b/homeassistant/components/modbus_connection/manifest.json new file mode 100644 index 000000000000..68e9dab5b28d --- /dev/null +++ b/homeassistant/components/modbus_connection/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "modbus_connection", + "name": "Modbus Connection", + "codeowners": ["@home-assistant/core"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/modbus_connection", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["modbus_connection", "tmodbus"], + "quality_scale": "bronze", + "requirements": ["modbus-connection[tmodbus]==3.2.0"] +} diff --git a/homeassistant/components/modbus_connection/quality_scale.yaml b/homeassistant/components/modbus_connection/quality_scale.yaml new file mode 100644 index 000000000000..5f23e30d236d --- /dev/null +++ b/homeassistant/components/modbus_connection/quality_scale.yaml @@ -0,0 +1,119 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register any service actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. It owns a connection and hands out units; + consumer integrations poll through their own coordinators. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not register any service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: This integration provides no entities. + entity-unique-id: + status: exempt + comment: This integration provides no entities. + has-entity-name: + status: exempt + comment: This integration provides no entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: + status: exempt + comment: > + A Modbus endpoint (a TCP host/port or a serial device path) exposes no + hardware identifier to use as a unique ID. Duplicate connections are + instead prevented in the config flow by matching host/port or device path + against the existing entries. + # Silver + action-exceptions: + status: exempt + comment: This integration does not register any service actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: This integration provides no entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + This integration provides no entities; availability is surfaced to + consumers via on_connection_lost and failing reads. + parallel-updates: + status: exempt + comment: This integration provides no entity platforms. + reauthentication-flow: + status: exempt + comment: A Modbus link has no authentication. + test-coverage: done + # Gold + devices: + status: exempt + comment: This integration provides connections, not devices or entities. + diagnostics: todo + discovery: + status: exempt + comment: Modbus links are not discoverable. + discovery-update-info: + status: exempt + comment: Modbus links are not discoverable. + docs-data-update: + status: exempt + comment: This integration provides no entities to update. + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: This integration is a connection provider, not a device integration. + docs-supported-functions: + status: exempt + comment: This integration provides no entities. + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration provides no devices. + entity-category: + status: exempt + comment: This integration provides no entities. + entity-device-class: + status: exempt + comment: This integration provides no entities. + entity-disabled-by-default: + status: exempt + comment: This integration provides no entities. + entity-translations: + status: exempt + comment: This integration provides no entities. + exception-translations: todo + icon-translations: + status: exempt + comment: This integration provides no entities. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repairable issues are raised. + stale-devices: + status: exempt + comment: This integration provides no devices. + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration talks Modbus, not HTTP. + strict-typing: done diff --git a/homeassistant/components/modbus_connection/strings.json b/homeassistant/components/modbus_connection/strings.json new file mode 100644 index 000000000000..1e9e159835ad --- /dev/null +++ b/homeassistant/components/modbus_connection/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_open_serial_port": "Failed to open the serial port" + }, + "step": { + "network": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Modbus gateway or device.", + "port": "The TCP port the Modbus gateway listens on (default 502)." + }, + "title": "Network connection" + }, + "serial": { + "data": { + "baudrate": "Baud rate", + "bytesize": "Byte size", + "device": "[%key:common::config_flow::data::device%]", + "parity": "Parity", + "stopbits": "Stop bits" + }, + "data_description": { + "baudrate": "The serial baud rate the device communicates at.", + "bytesize": "The number of data bits.", + "device": "The serial port the Modbus device is connected to, e.g. /dev/ttyUSB0.", + "parity": "The serial parity (None, Even or Odd).", + "stopbits": "The number of stop bits." + }, + "title": "Serial connection" + }, + "user": { + "description": "How is the Modbus network connected?", + "menu_options": { + "network": "Network", + "serial": "Serial" + } + } + } + }, + "exceptions": { + "connection_not_ready": { + "message": "Modbus connection not ready" + } + } +} diff --git a/requirements_all.txt b/requirements_all.txt index 43c9156c948c..130bbeef2341 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1585,6 +1585,9 @@ mitsubishi-comfort==0.3.2 # homeassistant.components.moat moat-ble==0.1.1 +# homeassistant.components.modbus_connection +modbus-connection[tmodbus]==3.2.0 + # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 diff --git a/tests/components/modbus_connection/__init__.py b/tests/components/modbus_connection/__init__.py new file mode 100644 index 000000000000..ecbad3432af6 --- /dev/null +++ b/tests/components/modbus_connection/__init__.py @@ -0,0 +1 @@ +"""Tests for the Modbus Connection integration.""" diff --git a/tests/components/modbus_connection/conftest.py b/tests/components/modbus_connection/conftest.py new file mode 100644 index 000000000000..379fcd664435 --- /dev/null +++ b/tests/components/modbus_connection/conftest.py @@ -0,0 +1,63 @@ +"""Common fixtures for the Modbus Connection tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from modbus_connection.mock import MockModbusConnection +import pytest + +from homeassistant.components.modbus_connection.const import CONNECTION_TCP, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Prevent the created entry from actually setting up during flow tests.""" + with patch( + "homeassistant.components.modbus_connection.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_connect( + mock_modbus_connection: MockModbusConnection, +) -> Generator[AsyncMock]: + """Patch the backend connect functions to return the mock connection.""" + connect = AsyncMock(return_value=mock_modbus_connection) + with ( + patch("homeassistant.components.modbus_connection.connect_tcp", connect), + patch("homeassistant.components.modbus_connection.connect_serial", connect), + ): + yield connect + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a TCP connection config entry, already added to hass.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="1.2.3.4:502", + data={CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, +) -> MockConfigEntry: + """Set up the connection entry (loaded). + + Relies on ``mock_config_entry`` already being in hass. + """ + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/modbus_connection/test_config_flow.py b/tests/components/modbus_connection/test_config_flow.py new file mode 100644 index 000000000000..ab148c49b399 --- /dev/null +++ b/tests/components/modbus_connection/test_config_flow.py @@ -0,0 +1,138 @@ +"""Tests for the Modbus Connection config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +from modbus_connection import ModbusConnectionError +from modbus_connection.mock import MockModbusConnection +import pytest + +from homeassistant.components.modbus_connection.const import ( + CONF_BAUDRATE, + CONF_BYTESIZE, + CONF_PARITY, + CONF_STOPBITS, + CONNECTION_SERIAL, + CONNECTION_TCP, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +SERIAL_INPUT = { + CONF_DEVICE: "/dev/ttyUSB0", + CONF_BAUDRATE: 9600, + CONF_PARITY: "N", + CONF_STOPBITS: 1, + CONF_BYTESIZE: 8, +} + + +async def _start_menu(hass: HomeAssistant, step: str) -> str: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert set(result["menu_options"]) == {"network", "serial"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": step} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == step + return result["flow_id"] + + +@pytest.mark.usefixtures("mock_connect", "mock_setup_entry") +async def test_network_flow(hass: HomeAssistant) -> None: + """The network step opens the connection and creates an entry.""" + flow_id = await _start_menu(hass, "network") + result = await hass.config_entries.flow.async_configure( + flow_id, {CONF_HOST: "1.2.3.4", CONF_PORT: 502} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_TYPE: CONNECTION_TCP, + CONF_HOST: "1.2.3.4", + CONF_PORT: 502, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_network_cannot_connect_then_recovers( + hass: HomeAssistant, + mock_connect: AsyncMock, + mock_modbus_connection: MockModbusConnection, +) -> None: + """A failed probe shows an error; a later success creates the entry.""" + flow_id = await _start_menu(hass, "network") + mock_connect.side_effect = ModbusConnectionError("nope") + result = await hass.config_entries.flow.async_configure( + flow_id, {CONF_HOST: "1.2.3.4", CONF_PORT: 502} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_connect.side_effect = None + mock_connect.return_value = mock_modbus_connection + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_PORT: 502} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_connect", "mock_setup_entry") +async def test_serial_flow(hass: HomeAssistant) -> None: + """The serial step opens the connection and creates a serial entry.""" + flow_id = await _start_menu(hass, "serial") + result = await hass.config_entries.flow.async_configure(flow_id, SERIAL_INPUT) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_TYPE: CONNECTION_SERIAL, **SERIAL_INPUT} + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_serial_cannot_open(hass: HomeAssistant, mock_connect: AsyncMock) -> None: + """A failed serial open shows the serial-specific error.""" + flow_id = await _start_menu(hass, "serial") + mock_connect.side_effect = ModbusConnectionError("nope") + result = await hass.config_entries.flow.async_configure(flow_id, SERIAL_INPUT) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_open_serial_port"} + + +@pytest.mark.parametrize( + ("step", "data", "user_input"), + [ + pytest.param( + "network", + {CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, + {CONF_HOST: "1.2.3.4", CONF_PORT: 502}, + id="network", + ), + pytest.param( + "serial", + {CONF_TYPE: CONNECTION_SERIAL, **SERIAL_INPUT}, + SERIAL_INPUT, + id="serial", + ), + ], +) +async def test_duplicate_aborts( + hass: HomeAssistant, + step: str, + data: dict[str, Any], + user_input: dict[str, Any], +) -> None: + """Re-adding an already-configured link aborts before opening it. + + The dedupe runs before opening the connection, so no connect is needed. + """ + MockConfigEntry(domain=DOMAIN, data=data).add_to_hass(hass) + + flow_id = await _start_menu(hass, step) + result = await hass.config_entries.flow.async_configure(flow_id, user_input) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/modbus_connection/test_init.py b/tests/components/modbus_connection/test_init.py new file mode 100644 index 000000000000..d2f5b13e9c42 --- /dev/null +++ b/tests/components/modbus_connection/test_init.py @@ -0,0 +1,80 @@ +"""Tests for Modbus Connection setup, teardown and the async_get_unit accessor.""" + +from unittest.mock import AsyncMock, patch + +from modbus_connection import ModbusConnectionError +from modbus_connection.mock import MockModbusConnection, MockModbusUnit +import pytest + +from homeassistant.components.modbus_connection import ( + ConnectionNotReady, + async_get_unit, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, +) -> None: + """A connection entry loads, exposes runtime data, and closes on unload.""" + assert init_integration.state is ConfigEntryState.LOADED + assert init_integration.runtime_data is mock_modbus_connection + assert mock_modbus_connection.connected is True + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + assert init_integration.state is ConfigEntryState.NOT_LOADED + assert mock_modbus_connection.connected is False + + +async def test_setup_retry_when_connect_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, +) -> None: + """A failed connect raises ConfigEntryNotReady (setup retry).""" + mock_connect.side_effect = ModbusConnectionError("boom") + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_connection_lost_schedules_reload( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, +) -> None: + """Losing the connection schedules a reload of the entry.""" + with patch.object(hass.config_entries, "async_schedule_reload") as schedule_reload: + mock_modbus_connection.simulate_connection_lost() + await hass.async_block_till_done() + + schedule_reload.assert_called_once_with(init_integration.entry_id) + + +async def test_get_unit_returns_connection_unit( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """async_get_unit hands back the connection's own unit handle.""" + assert async_get_unit(hass, init_integration.entry_id, 1) is mock_modbus_unit + + +async def test_get_unit_not_ready_when_missing_or_unloaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """An unknown or not-loaded connection entry raises ConnectionNotReady.""" + with pytest.raises(ConnectionNotReady): + async_get_unit(hass, "does-not-exist", 1) + + # mock_config_entry is added to hass but never set up -> not LOADED. + with pytest.raises(ConnectionNotReady): + async_get_unit(hass, mock_config_entry.entry_id, 1)