Compare commits

...

6 Commits

Author SHA1 Message Date
Mike Degatano c86d7052c0 Fixes from feedback 2026-05-26 21:06:53 +00:00
Mike Degatano 07965e468b Fix prek errors 2026-05-26 20:24:10 +00:00
Mike Degatano 5972dc182b Fix config entry from feedback
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 20:24:07 +00:00
Mike Degatano 7ad535841a Hassfest changes for config flow 2026-05-26 20:24:07 +00:00
Mike Degatano d9fae7fecf Migrate analytics integration to config entry setup
- Add config_flow.py with a minimal system config flow
- Split async_setup (lightweight: YAML config, labs feature, discovery
  flow, websocket/HTTP registration) from async_setup_entry (heavy:
  Analytics init, load, scheduling, listeners)
- Add async_unload_entry that cancels scheduled analytics tasks
- Thread snapshots_url from YAML through hass.data so it reaches
  async_setup_entry without persisting to config entry data, keeping
  the option as a hidden developer-only YAML setting for now
- Catch HassioNotReadyError from Analytics.load and raise
  ConfigEntryNotReady so setup is retried when Supervisor is not yet
  ready
- Register websocket commands and HTTP view in async_setup so they
  survive entry reload; guard both handlers with ERR_NOT_FOUND when
  the entry is not loaded
- Replace async_listen_once(EVENT_HOMEASSISTANT_STARTED) with
  async_at_started so the schedule starts immediately on reload when
  HA is already running
- Add cancel_scheduled() to Analytics class
- Update stale comments in Analytics.load and send_analytics
- Add supervisor_not_ready exception translation key
- Add tests for: ConfigEntryNotReady on supervisor failure, schedule
  fires and sends analytics, unload stops the schedule, websocket
  error when entry not loaded, snapshots_url routes to custom URL

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 20:24:07 +00:00
Ronald van der Meer 2f3f91ec82 Require Duco Connectivity API 2.1 for new setups (#170766) 2026-05-26 22:21:39 +02:00
15 changed files with 716 additions and 65 deletions
+52 -18
View File
@@ -5,8 +5,12 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import labs, websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.components.hassio import HassioNotReadyError
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -49,6 +53,7 @@ CONFIG_SCHEMA = vol.Schema(
)
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
LABS_SNAPSHOT_FEATURE = "snapshots"
@@ -57,18 +62,39 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the analytics integration."""
analytics_config = config.get(DOMAIN, {})
snapshots_url: str | None = None
if CONF_SNAPSHOTS_URL in analytics_config:
await labs.async_update_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
)
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
else:
snapshots_url = None
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
websocket_api.async_register_command(hass, websocket_analytics)
websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Analytics from a config entry."""
snapshots_url = hass.data.get(_DATA_SNAPSHOTS_URL)
analytics = Analytics(hass, snapshots_url)
# Load stored data
await analytics.load()
try:
await analytics.load()
except HassioNotReadyError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="supervisor_not_ready",
) from err
started = False
@@ -80,26 +106,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if started:
await analytics.async_schedule()
async def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event."""
async def start_schedule(hass: HomeAssistant) -> None:
"""Start the send schedule once Home Assistant has started."""
nonlocal started
started = True
await analytics.async_schedule()
labs.async_subscribe_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
entry.async_on_unload(
labs.async_subscribe_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
)
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
websocket_api.async_register_command(hass, websocket_analytics)
websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
entry.async_on_unload(async_at_started(hass, start_schedule))
hass.data[DATA_COMPONENT] = analytics
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an Analytics config entry."""
analytics = hass.data.pop(DATA_COMPONENT)
analytics.cancel_scheduled()
return True
@callback
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "analytics"})
@@ -109,7 +139,9 @@ def websocket_analytics(
msg: dict[str, Any],
) -> None:
"""Return analytics preferences."""
analytics = hass.data[DATA_COMPONENT]
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
connection.send_result(
msg["id"],
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
@@ -130,8 +162,10 @@ async def websocket_analytics_preferences(
msg: dict[str, Any],
) -> None:
"""Update analytics preferences."""
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
preferences = msg[ATTR_PREFERENCES]
analytics = hass.data[DATA_COMPONENT]
await analytics.save_preferences(preferences)
await analytics.async_schedule()
+16 -10
View File
@@ -299,12 +299,8 @@ class Analytics:
self._data = AnalyticsData.from_dict(stored)
if self.supervisor and not self.onboarded:
# This may raise HassioNotReadyError if Supervisor was unreachable
# during setup of the Supervisor integration. That will fail setup
# of this integration. However there is no better option at this time
# since we need to get the diagnostic setting from Supervisor to correctly
# setup this integration and we can't raise ConfigEntryNotReady to
# trigger a retry from async_setup.
# This may raise HassioNotReadyError if Supervisor was unreachable.
# The caller is responsible for handling this and triggering a retry.
supervisor_info = hassio.get_supervisor_info(self._hass)
# User have not configured analytics, get this setting from the supervisor
@@ -349,10 +345,10 @@ class Analytics:
await self._save()
if self.supervisor:
# get_supervisor_info was called during setup so we can't get here
# if it raised. The others may raise HassioNotReadyError if only some
# data was successfully fetched from Supervisor
supervisor_info = hassio.get_supervisor_info(hass)
# Try to pull Supervisor information, but don't fail if some or all
# of it is unavailable due to setup failures in the hassio integration.
with contextlib.suppress(hassio.HassioNotReadyError):
supervisor_info = hassio.get_supervisor_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError):
operating_system_info = hassio.get_os_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError):
@@ -630,6 +626,16 @@ class Analytics:
err,
)
@callback
def cancel_scheduled(self) -> None:
"""Cancel all scheduled analytics tasks."""
if self._basic_scheduled is not None:
self._basic_scheduled()
self._basic_scheduled = None
if self._snapshot_scheduled is not None:
self._snapshot_scheduled()
self._snapshot_scheduled = None
async def async_schedule(self) -> None:
"""Schedule analytics."""
if not self.onboarded:
@@ -0,0 +1,19 @@
"""Config flow for Analytics integration."""
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Analytics."""
VERSION = 1
async def async_step_system(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
return self.async_create_entry(title="Analytics", data={})
@@ -3,6 +3,7 @@
"name": "Analytics",
"after_dependencies": ["energy", "hassio", "recorder"],
"codeowners": ["@home-assistant/core"],
"config_flow": true,
"dependencies": ["api", "websocket_api", "http"],
"documentation": "https://www.home-assistant.io/integrations/analytics",
"integration_type": "system",
@@ -14,5 +15,6 @@
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
}
},
"quality_scale": "internal"
"quality_scale": "internal",
"single_config_entry": true
}
@@ -1,4 +1,9 @@
{
"exceptions": {
"supervisor_not_ready": {
"message": "Supervisor was not ready during setup, will retry"
}
},
"preview_features": {
"snapshots": {
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
+17 -1
View File
@@ -15,6 +15,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .validation import UnsupportedBoardError, async_get_supported_board_info
_LOGGER = logging.getLogger(__name__)
@@ -43,6 +44,11 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
try:
box_name, _ = await self._validate_input(discovery_info.ip)
except UnsupportedBoardError:
_LOGGER.debug(
"Unsupported Duco board discovered via DHCP at %s", discovery_info.ip
)
return self.async_abort(reason="unsupported_board")
except DucoConnectionError:
return self.async_abort(reason="cannot_connect")
except DucoError:
@@ -61,6 +67,12 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle zeroconf discovery."""
try:
box_name, mac = await self._validate_input(discovery_info.host)
except UnsupportedBoardError:
_LOGGER.debug(
"Unsupported Duco board discovered via zeroconf at %s",
discovery_info.host,
)
return self.async_abort(reason="unsupported_board")
except DucoConnectionError:
return self.async_abort(reason="cannot_connect")
except DucoError:
@@ -102,6 +114,8 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
box_name, mac = await self._validate_input(user_input[CONF_HOST])
except UnsupportedBoardError:
errors["base"] = "unsupported_board"
except DucoConnectionError:
errors["base"] = "cannot_connect"
except DucoError:
@@ -133,6 +147,8 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
box_name, mac = await self._validate_input(user_input[CONF_HOST])
except UnsupportedBoardError:
errors["base"] = "unsupported_board"
except DucoConnectionError:
errors["base"] = "cannot_connect"
except DucoError:
@@ -162,6 +178,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
session=async_get_clientsession(self.hass),
host=host,
)
board_info = await client.async_get_board_info()
board_info = await async_get_supported_board_info(client)
lan_info = await client.async_get_lan_info()
return board_info.box_name, lan_info.mac
+18 -16
View File
@@ -4,7 +4,11 @@ from dataclasses import dataclass
import logging
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco_connectivity.exceptions import (
DucoConnectionError,
DucoError,
DucoResponseError,
)
from duco_connectivity.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
@@ -13,6 +17,7 @@ from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
from .validation import UnsupportedBoardError, async_get_supported_board_info
_LOGGER = logging.getLogger(__name__)
@@ -52,7 +57,18 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
async def _async_setup(self) -> None:
"""Fetch board info once during initial setup."""
try:
self.board_info = await self.client.async_get_board_info()
self.board_info = await async_get_supported_board_info(self.client)
except UnsupportedBoardError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="unsupported_board",
) from err
except DucoResponseError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -70,20 +86,6 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
"""Fetch node data from the Duco box."""
try:
nodes = await self.client.async_get_nodes()
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
try:
lan_info = await self.client.async_get_lan_info()
except DucoConnectionError as err:
raise UpdateFailed(
+7 -2
View File
@@ -6,11 +6,13 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device you entered belongs to a different Duco box.",
"unknown": "[%key:common::config_flow::error::unknown%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"unsupported_board": "This Duco system is not supported by this integration. The integration requires a Duco Connectivity Board running public API 2.1 or newer."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"unsupported_board": "[%key:component::duco::config::abort::unsupported_board%]"
},
"step": {
"discovery_confirm": {
@@ -98,6 +100,9 @@
},
"rate_limit_exceeded": {
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
},
"unsupported_board": {
"message": "[%key:component::duco::config::abort::unsupported_board%]"
}
},
"system_health": {
@@ -0,0 +1,58 @@
"""Validation helpers for supported Duco systems."""
from awesomeversion import (
AwesomeVersion,
AwesomeVersionStrategy,
AwesomeVersionStrategyException,
)
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoResponseError
from duco_connectivity.models import BoardInfo
# Newer Connectivity boards expose /info with PublicApiVersion. We use that
# endpoint to distinguish supported Connectivity hardware from older
# Communication board V1 hardware.
_MIN_PUBLIC_API_VERSION = AwesomeVersion(
"2.1", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
)
class UnsupportedBoardError(Exception):
"""Raised when the Duco system is not supported by this integration."""
def validate_board_support(board_info: BoardInfo) -> None:
"""Raise UnsupportedBoardError if the board does not meet support requirements."""
version = board_info.public_api_version
if version is None:
raise UnsupportedBoardError("Board did not report a public API version")
try:
parsed_version = AwesomeVersion(
version, ensure_strategy=AwesomeVersionStrategy.SIMPLEVER
)
except AwesomeVersionStrategyException as err:
raise UnsupportedBoardError(
f"Board reported malformed public API version: {version}"
) from err
if parsed_version < _MIN_PUBLIC_API_VERSION:
raise UnsupportedBoardError(
"Board public API version "
f"{version} is below the supported minimum {_MIN_PUBLIC_API_VERSION}"
)
async def async_get_supported_board_info(client: DucoClient) -> BoardInfo:
"""Fetch and validate board info for a supported Duco system."""
try:
board_info = await client.async_get_board_info()
except DucoResponseError as err:
if err.status == 404:
# Duco indicated that Communication board V1 does not implement
# /info, so a 404 is enough to treat the device as unsupported.
raise UnsupportedBoardError(
"Board does not expose the /info endpoint"
) from err
raise
validate_board_support(board_info)
return board_info
+1
View File
@@ -59,6 +59,7 @@ FLOWS = {
"amberelectric",
"ambient_network",
"ambient_station",
"analytics",
"analytics_insights",
"android_ip_webcam",
"androidtv",
+151 -1
View File
@@ -6,15 +6,18 @@ from unittest.mock import patch
import pytest
from homeassistant.components.analytics import LABS_SNAPSHOT_FEATURE
from homeassistant.components.analytics import CONF_SNAPSHOTS_URL, LABS_SNAPSHOT_FEATURE
from homeassistant.components.analytics.const import (
BASIC_ENDPOINT_URL,
BASIC_ENDPOINT_URL_DEV,
DOMAIN,
SNAPSHOT_DEFAULT_URL,
SNAPSHOT_URL_PATH,
STORAGE_KEY,
)
from homeassistant.components.hassio import HassioNotReadyError
from homeassistant.components.labs import async_update_preview_feature
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -37,6 +40,153 @@ async def test_setup(hass: HomeAssistant) -> None:
assert DOMAIN in hass.data
async def test_setup_with_snapshots_url(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test setup with snapshots_url in YAML config sends snapshots to that URL."""
custom_url = "https://custom-snapshot-endpoint.example.com"
snapshot_endpoint = custom_url + SNAPSHOT_URL_PATH
aioclient_mock.post(snapshot_endpoint, status=200, json={})
with patch(
"homeassistant.components.analytics.analytics._async_snapshot_payload",
return_value={"mock": {}},
):
assert await async_setup_component(hass, "labs", {})
assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {CONF_SNAPSHOTS_URL: custom_url}}
)
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id(
{"type": "analytics/preferences", "preferences": {"snapshots": True}}
)
assert (await ws_client.receive_json())["success"]
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
await hass.async_block_till_done()
assert any(str(call[1]) == snapshot_endpoint for call in aioclient_mock.mock_calls)
async def test_setup_entry_supervisor_not_ready(hass: HomeAssistant) -> None:
"""Test that HassioNotReadyError raises ConfigEntryNotReady."""
with (
patch(
"homeassistant.components.analytics.analytics.is_hassio",
return_value=True,
),
patch(
"homeassistant.components.hassio.get_supervisor_info",
side_effect=HassioNotReadyError,
),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_schedule_starts_and_sends_analytics(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that the analytics schedule fires and sends analytics after time travel."""
aioclient_mock.post(BASIC_ENDPOINT_URL, status=200)
aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=200)
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION):
await ws_client.send_json_auto_id(
{"type": "analytics/preferences", "preferences": {"base": True}}
)
assert (await ws_client.receive_json())["success"]
assert len(aioclient_mock.mock_calls) == 0
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=901))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
async def test_unload_entry(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that unloading the config entry stops the analytics schedule."""
aioclient_mock.post(BASIC_ENDPOINT_URL, status=200)
aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=200)
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION):
await ws_client.send_json_auto_id(
{"type": "analytics/preferences", "preferences": {"base": True}}
)
await ws_client.receive_json()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=901))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
entry = hass.config_entries.async_entries(DOMAIN)[0]
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=2))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
@pytest.mark.parametrize(
("ws_type", "ws_options"),
[("analytics", {}), ("analytics/preferences", {"preferences": {"base": True}})],
)
async def test_websocket_not_loaded(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
ws_type: str,
ws_options: dict[str, Any],
) -> None:
"""Test websocket returns error when analytics entry failed to load."""
with (
patch(
"homeassistant.components.analytics.analytics.is_hassio",
return_value=True,
),
patch(
"homeassistant.components.hassio.get_supervisor_info",
side_effect=HassioNotReadyError,
),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id({"type": ws_type} | ws_options)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "not_found"
@pytest.mark.usefixtures("mock_snapshot_payload")
async def test_labs_feature_toggle(
hass: HomeAssistant,
+42
View File
@@ -30,6 +30,48 @@ TEST_MAC = "aa:bb:cc:dd:ee:ff"
USER_INPUT = {CONF_HOST: TEST_HOST}
UNSUPPORTED_BOARD_INFOS = [
pytest.param(
BoardInfo(
box_name="SILENT_CONNECT",
box_sub_type_name="Eu",
serial_board_box="ABC123",
serial_board_comm="DEF456",
serial_duco_box="GHI789",
serial_duco_comm="JKL012",
time=1700000000,
public_api_version="2.0",
),
id="version-too-low",
),
pytest.param(
BoardInfo(
box_name="SILENT_CONNECT",
box_sub_type_name="Eu",
serial_board_box="ABC123",
serial_board_comm="DEF456",
serial_duco_box="GHI789",
serial_duco_comm="JKL012",
time=1700000000,
public_api_version=None,
),
id="missing-version",
),
pytest.param(
BoardInfo(
box_name="SILENT_CONNECT",
box_sub_type_name="Eu",
serial_board_box="ABC123",
serial_board_comm="DEF456",
serial_duco_box="GHI789",
serial_duco_comm="JKL012",
time=1700000000,
public_api_version="2.1.0-beta",
),
id="malformed-version",
),
]
def _node_from_dict(data: dict[str, Any]) -> Node:
"""Convert a node fixture payload into a Duco node model."""
+250 -2
View File
@@ -3,7 +3,13 @@
from ipaddress import IPv4Address
from unittest.mock import ANY, AsyncMock, patch
from duco_connectivity import BoardInfo, DucoConnectionError, DucoError, LanInfo
from duco_connectivity import (
BoardInfo,
DucoConnectionError,
DucoError,
DucoResponseError,
LanInfo,
)
import pytest
from homeassistant.components.duco.const import DOMAIN
@@ -14,7 +20,7 @@ from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .conftest import TEST_HOST, TEST_MAC, USER_INPUT
from .conftest import TEST_HOST, TEST_MAC, UNSUPPORTED_BOARD_INFOS, USER_INPUT
from tests.common import MockConfigEntry
@@ -34,6 +40,48 @@ DHCP_DISCOVERY = DhcpServiceInfo(
macaddress="aabbccddeeff",
)
_SUPPORTED_BOARD_INFOS = [
pytest.param(
BoardInfo(
box_name="ENERGY",
box_sub_type_name="Eu",
serial_board_box="ABC123",
serial_board_comm="DEF456",
serial_duco_box="GHI789",
serial_duco_comm="JKL012",
time=1700000000,
public_api_version="2.5",
),
id="energy-supported",
),
pytest.param(
BoardInfo(
box_name="FOCUS",
box_sub_type_name="Eu",
serial_board_box="ABC123",
serial_board_comm="DEF456",
serial_duco_box="GHI789",
serial_duco_comm="JKL012",
time=1700000000,
public_api_version="2.5",
),
id="focus-supported",
),
pytest.param(
BoardInfo(
box_name="SOMETHING_NEW",
box_sub_type_name="Eu",
serial_board_box="ABC123",
serial_board_comm="DEF456",
serial_duco_box="GHI789",
serial_duco_comm="JKL012",
time=1700000000,
public_api_version="2.5",
),
id="unknown-box-name-supported",
),
]
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_success(
@@ -62,6 +110,8 @@ async def test_user_flow_success(
[
(DucoConnectionError("Connection refused"), "cannot_connect"),
(DucoError("Unexpected error"), "unknown"),
(DucoResponseError(500, "/info"), "unknown"),
(DucoResponseError(404, "/info"), "unsupported_board"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
@@ -190,6 +240,7 @@ async def test_zeroconf_discovery_already_configured_same_ip(
[
(DucoConnectionError("Connection refused"), "cannot_connect"),
(DucoError("Unexpected error"), "unknown"),
(DucoResponseError(404, "/info"), "unsupported_board"),
],
)
async def test_zeroconf_discovery_exceptions(
@@ -285,6 +336,7 @@ async def test_reconfigure_flow_wrong_device(
[
(DucoConnectionError("Connection refused"), "cannot_connect"),
(DucoError("Unexpected error"), "unknown"),
(DucoResponseError(500, "/info"), "unknown"),
],
)
async def test_reconfigure_flow_error(
@@ -317,6 +369,35 @@ async def test_reconfigure_flow_error(
assert result["reason"] == "reconfigure_successful"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_flow_without_info_endpoint(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow rejects boards that do not expose the supported API."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
mock_duco_client.async_get_board_info.side_effect = DucoResponseError(404, "/info")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.50"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["errors"] == {"base": "unsupported_board"}
mock_duco_client.async_get_board_info.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.200"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_dhcp_discovery_new_device(
hass: HomeAssistant, mock_duco_client: AsyncMock
@@ -391,6 +472,7 @@ async def test_dhcp_discovery_already_configured_same_ip(
[
(DucoConnectionError("Connection refused"), "cannot_connect"),
(DucoError("Unexpected error"), "unknown"),
(DucoResponseError(404, "/info"), "unsupported_board"),
],
)
async def test_dhcp_discovery_exceptions(
@@ -449,6 +531,172 @@ async def test_dhcp_discovery_exception_recovery(
assert result["result"].unique_id == TEST_MAC
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize("unsupported_board_info", UNSUPPORTED_BOARD_INFOS)
async def test_user_flow_unsupported_board_from_board_info(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
mock_board_info: BoardInfo,
unsupported_board_info: BoardInfo,
) -> None:
"""Test user flow shows unsupported_board error when board validation fails."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_duco_client.async_get_board_info.return_value = unsupported_board_info
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "unsupported_board"}
mock_duco_client.async_get_board_info.return_value = mock_board_info
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize("supported_board_info", _SUPPORTED_BOARD_INFOS)
async def test_user_flow_allows_api_compatible_board_info(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
supported_board_info: BoardInfo,
) -> None:
"""Test user flow allows boards that expose a compatible API version."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_duco_client.async_get_board_info.return_value = supported_board_info
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == str(supported_board_info.box_name)
assert result["result"].unique_id == TEST_MAC
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize("unsupported_board_info", UNSUPPORTED_BOARD_INFOS)
async def test_reconfigure_flow_unsupported_board_from_board_info(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_board_info: BoardInfo,
unsupported_board_info: BoardInfo,
) -> None:
"""Test reconfigure flow shows unsupported_board when board validation fails."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
mock_duco_client.async_get_board_info.return_value = unsupported_board_info
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.50"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["errors"] == {"base": "unsupported_board"}
mock_duco_client.async_get_board_info.return_value = mock_board_info
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.1.200"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
@pytest.mark.parametrize("supported_board_info", _SUPPORTED_BOARD_INFOS)
async def test_zeroconf_discovery_allows_api_compatible_board_info(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
supported_board_info: BoardInfo,
) -> None:
"""Test zeroconf discovery allows boards that expose a compatible API version."""
mock_duco_client.async_get_board_info.return_value = supported_board_info
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["description_placeholders"] == {
"name": str(supported_board_info.box_name)
}
@pytest.mark.parametrize("unsupported_board_info", UNSUPPORTED_BOARD_INFOS)
async def test_zeroconf_discovery_unsupported_board_from_board_info(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
unsupported_board_info: BoardInfo,
) -> None:
"""Test zeroconf discovery aborts with unsupported_board when board validation fails."""
mock_duco_client.async_get_board_info.return_value = unsupported_board_info
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unsupported_board"
@pytest.mark.parametrize("supported_board_info", _SUPPORTED_BOARD_INFOS)
async def test_dhcp_discovery_allows_api_compatible_board_info(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
supported_board_info: BoardInfo,
) -> None:
"""Test DHCP discovery allows boards that expose a compatible API version."""
mock_duco_client.async_get_board_info.return_value = supported_board_info
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["description_placeholders"] == {
"name": str(supported_board_info.box_name)
}
@pytest.mark.parametrize("unsupported_board_info", UNSUPPORTED_BOARD_INFOS)
async def test_dhcp_discovery_unsupported_board_from_board_info(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
unsupported_board_info: BoardInfo,
) -> None:
"""Test DHCP discovery aborts with unsupported_board when board validation fails."""
mock_duco_client.async_get_board_info.return_value = unsupported_board_info
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unsupported_board"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_initializes_client_with_host(
hass: HomeAssistant, mock_board_info: BoardInfo, mock_lan_info: LanInfo
+13 -6
View File
@@ -62,18 +62,17 @@ async def test_diagnostics_connection_error(
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_diagnostics_without_optional_board_metadata(
async def test_diagnostics_without_optional_software_version(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
) -> None:
"""Test that None board fields are omitted from the diagnostics payload."""
"""Test that an optional software version is omitted from diagnostics."""
# BoardInfo is a frozen dataclass, so the mock must be updated before
# integration setup — the coordinator stores board_info during async_setup.
mock_duco_client.async_get_board_info.return_value = replace(
mock_duco_client.async_get_board_info.return_value,
public_api_version=None,
software_version=None,
)
mock_config_entry.add_to_hass(hass)
@@ -84,7 +83,9 @@ async def test_diagnostics_without_optional_board_metadata(
hass, hass_client, mock_config_entry
)
assert "public_api_version" not in diagnostics["board_info"]
assert diagnostics["board_info"]["public_api_version"] == str(
mock_duco_client.async_get_board_info.return_value.public_api_version
)
assert "software_version" not in diagnostics["board_info"]
@@ -96,10 +97,16 @@ async def test_diagnostics_without_optional_api_metadata(
mock_duco_client: AsyncMock,
) -> None:
"""Test diagnostics when optional API metadata is absent."""
mock_duco_client.async_get_api_info.return_value = ApiInfo(api_version="2.5")
mock_duco_client.async_get_api_info.return_value = ApiInfo(
api_version=mock_duco_client.async_get_api_info.return_value.api_version
)
diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
assert diagnostics["api_info"] == {"public_api_version": "2.5"}
assert diagnostics["api_info"] == {
"public_api_version": str(
mock_duco_client.async_get_api_info.return_value.api_version
)
}
+64 -8
View File
@@ -8,6 +8,7 @@ from duco_connectivity import (
DiagStatus,
DucoConnectionError,
DucoError,
DucoResponseError,
LanInfo,
Node,
)
@@ -18,28 +19,47 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import TEST_HOST, TEST_MAC
from .conftest import TEST_HOST, TEST_MAC, UNSUPPORTED_BOARD_INFOS
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("method", "exception", "expected_state"),
(
"method",
"exception",
"expected_state",
"expected_translation_key",
"has_error_translation_placeholder",
),
[
(
"async_get_board_info",
DucoConnectionError("Connection refused"),
ConfigEntryState.SETUP_RETRY,
None,
False,
),
(
"async_get_board_info",
DucoError("Unexpected API error"),
ConfigEntryState.SETUP_ERROR,
"api_error",
True,
),
(
"async_get_board_info",
DucoResponseError(500, "/info"),
ConfigEntryState.SETUP_ERROR,
"api_error",
True,
),
(
"async_get_nodes",
DucoConnectionError("Connection refused"),
ConfigEntryState.SETUP_RETRY,
None,
False,
),
],
)
@@ -50,6 +70,8 @@ async def test_setup_entry_error(
method: str,
exception: Exception,
expected_state: ConfigEntryState,
expected_translation_key: str | None,
has_error_translation_placeholder: bool,
) -> None:
"""Test that fetch errors during setup result in the correct state."""
getattr(mock_duco_client, method).side_effect = exception
@@ -58,15 +80,13 @@ async def test_setup_entry_error(
await hass.async_block_till_done()
assert mock_config_entry.state is expected_state
if (
method == "async_get_board_info"
and isinstance(exception, DucoError)
and expected_state is ConfigEntryState.SETUP_ERROR
):
assert mock_config_entry.error_reason_translation_key == "api_error"
assert mock_config_entry.error_reason_translation_key == expected_translation_key
if has_error_translation_placeholder:
assert mock_config_entry.error_reason_translation_placeholders == {
"error": repr(exception)
}
else:
assert mock_config_entry.error_reason_translation_placeholders is None
@pytest.mark.usefixtures("mock_duco_client")
@@ -78,6 +98,42 @@ async def test_setup_entry_success(
assert init_integration.state is ConfigEntryState.LOADED
@pytest.mark.parametrize("unsupported_board_info", UNSUPPORTED_BOARD_INFOS)
async def test_setup_entry_unsupported_board_info(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
unsupported_board_info: BoardInfo,
) -> None:
"""Test that unsupported board info blocks setup for existing entries."""
mock_duco_client.async_get_board_info.return_value = unsupported_board_info
mock_config_entry.add_to_hass(hass)
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_ERROR
assert mock_config_entry.error_reason_translation_key == "unsupported_board"
assert mock_config_entry.error_reason_translation_placeholders is None
async def test_setup_entry_unsupported_board_without_info_endpoint(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
) -> None:
"""Test that setup fails when the board does not expose /info."""
mock_duco_client.async_get_board_info.side_effect = DucoResponseError(404, "/info")
mock_config_entry.add_to_hass(hass)
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_ERROR
assert mock_config_entry.error_reason_translation_key == "unsupported_board"
assert mock_config_entry.error_reason_translation_placeholders is None
async def test_unload_entry(
hass: HomeAssistant,
init_integration: MockConfigEntry,