Add Modbus Connection integration

Layer 2 of the shared-Modbus-connection design: a new integration whose only job
is to own Modbus connection config entries (one per physical link) and publish a
live, backend-neutral connection that consumer integrations borrow units from.

- config flow: Network (TCP / RTU-over-TCP) or Serial (RTU, including network
  serial proxies). A duplicate link aborts before the connection is opened;
  otherwise the connection is opened to validate it
- runtime_data holds the live ModbusConnection (built via the tmodbus-backed
  modbus_connection.tmodbus connect functions); the entry owns close() and
  reloads on connection loss
- async_get_unit(hass, entry_id, unit_id): the only consumer touchpoint -
  returns a backend-neutral ModbusUnit, or raises ConnectionNotReady (a
  ConfigEntryNotReady, so consumers get setup-retry, and a ModbusError)
- quality_scale.yaml at bronze with strict typing (unique-config-entry exempt:
  Modbus endpoints have no hardware unique ID; entity/action rules exempt: the
  integration has no entities)
- tests: setup/unload, connect-failure retry, reload-on-loss, the config flow
  (network/serial happy paths, cannot_connect, cannot_open_serial_port, and a
  parametrized duplicate check) and the async_get_unit accessor

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016huRs96kGdQbNQopXseC4H
This commit is contained in:
Trovis Cook
2026-07-02 17:45:14 -04:00
committed by Paulus Schoutsen
co-authored by Claude Opus 4.8
parent bd6fbb734a
commit ff7292713c
13 changed files with 733 additions and 0 deletions
+1
View File
@@ -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.*
@@ -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)
@@ -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 {}
@@ -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
@@ -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
@@ -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"]
}
@@ -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
@@ -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"
}
}
}
+3
View File
@@ -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
@@ -0,0 +1 @@
"""Tests for the Modbus Connection integration."""
@@ -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
@@ -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"
@@ -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)