mirror of
https://github.com/home-assistant/core.git
synced 2026-08-29 10:21:09 -05:00
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:
committed by
Paulus Schoutsen
co-authored by
Claude Opus 4.8
parent
bd6fbb734a
commit
ff7292713c
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3
@@ -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)
|
||||
Reference in New Issue
Block a user