mirror of
https://github.com/home-assistant/core.git
synced 2026-05-27 03:05:50 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c86d7052c0 | |||
| 07965e468b | |||
| 5972dc182b | |||
| 7ad535841a | |||
| d9fae7fecf | |||
| 2f3f91ec82 |
@@ -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()
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Generated
+1
@@ -59,6 +59,7 @@ FLOWS = {
|
||||
"amberelectric",
|
||||
"ambient_network",
|
||||
"ambient_station",
|
||||
"analytics",
|
||||
"analytics_insights",
|
||||
"android_ip_webcam",
|
||||
"androidtv",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user