diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index a911f5398da..1f29be8e6b6 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,12 +1,18 @@ """The Homewizard integration.""" -from homewizard_energy import HomeWizardEnergy, HomeWizardEnergyV1, HomeWizardEnergyV2 +from homewizard_energy import ( + HomeWizardEnergy, + HomeWizardEnergyV1, + HomeWizardEnergyV2, + has_v2_api, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -31,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - clientsession=async_get_clientsession(hass), ) + await async_check_v2_support_and_create_issue(hass, entry) + coordinator = HWEnergyDeviceUpdateCoordinator(hass, api) try: await coordinator.async_config_entry_first_refresh() @@ -63,3 +71,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_check_v2_support_and_create_issue( + hass: HomeAssistant, entry: HomeWizardConfigEntry +) -> None: + """Check if the device supports v2 and create an issue if not.""" + + if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)): + return + + async_create_issue( + hass, + DOMAIN, + f"migrate_to_v2_api_{entry.entry_id}", + is_fixable=True, + is_persistent=False, + learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device", + translation_key="migrate_to_v2_api", + translation_placeholders={ + "title": entry.title, + }, + severity=IssueSeverity.WARNING, + data={"entry_id": entry.entry_id}, + ) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index fe78385381c..c94f590f000 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -5,28 +5,31 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from homewizard_energy import HomeWizardEnergyV1 -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +from homewizard_energy import ( + HomeWizardEnergy, + HomeWizardEnergyV1, + HomeWizardEnergyV2, + has_v2_api, +) +from homewizard_energy.errors import ( + DisabledError, + RequestError, + UnauthorizedError, + UnsupportedError, +) from homewizard_energy.models import Device import voluptuous as vol from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import TextSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import ( - CONF_API_ENABLED, - CONF_PRODUCT_NAME, - CONF_PRODUCT_TYPE, - CONF_SERIAL, - DOMAIN, - LOGGER, -) +from .const import CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, LOGGER class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): @@ -46,10 +49,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None if user_input is not None: try: - device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} + except UnauthorizedError: + # Device responded, so IP is correct. But we have to authorize + self.ip_address = user_input[CONF_IP_ADDRESS] + return await self.async_step_authorize() else: await self.async_set_unique_id( f"{device_info.product_type}_{device_info.serial}" @@ -73,22 +80,54 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step where we attempt to get a token.""" + assert self.ip_address + + # Tell device we want a token, user must now press the button within 30 seconds + # The first attempt will always fail, but this opens the window to press the button + token = await async_request_token(self.ip_address) + errors: dict[str, str] | None = None + + if token is None: + if user_input is not None: + errors = {"base": "authorization_failed"} + + return self.async_show_form(step_id="authorize", errors=errors) + + # Now we got a token, we can ask for some more info + + async with HomeWizardEnergyV2(self.ip_address, token=token) as api: + device_info = await api.device() + + data = { + CONF_IP_ADDRESS: self.ip_address, + CONF_TOKEN: token, + } + + await self.async_set_unique_id( + f"{device_info.product_type}_{device_info.serial}" + ) + self._abort_if_unique_id_configured(updates=data) + return self.async_create_entry( + title=f"{device_info.product_name}", + data=data, + ) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" + if ( - CONF_API_ENABLED not in discovery_info.properties - or CONF_PATH not in discovery_info.properties - or CONF_PRODUCT_NAME not in discovery_info.properties + CONF_PRODUCT_NAME not in discovery_info.properties or CONF_PRODUCT_TYPE not in discovery_info.properties or CONF_SERIAL not in discovery_info.properties ): return self.async_abort(reason="invalid_discovery_parameters") - if (discovery_info.properties[CONF_PATH]) != "/api/v1": - return self.async_abort(reason="unsupported_api_version") - self.ip_address = discovery_info.host self.product_type = discovery_info.properties[CONF_PRODUCT_TYPE] self.product_name = discovery_info.properties[CONF_PRODUCT_NAME] @@ -109,10 +148,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): This flow is triggered only by DHCP discovery of known devices. """ try: - device = await self._async_try_connect(discovery_info.ip) + device = await async_try_connect(discovery_info.ip) except RecoverableError as ex: LOGGER.error(ex) return self.async_abort(reason="unknown") + except UnauthorizedError: + return self.async_abort(reason="unsupported_api_version") await self.async_set_unique_id( f"{device.product_type}_{discovery_info.macaddress}" @@ -139,10 +180,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None if user_input is not None or not onboarding.async_is_onboarded(self.hass): try: - await self._async_try_connect(self.ip_address) + await async_try_connect(self.ip_address) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} + except UnauthorizedError: + return await self.async_step_authorize() else: return self.async_create_entry( title=self.product_name, @@ -172,25 +215,57 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle re-auth if API was disabled.""" - return await self.async_step_reauth_confirm() + self.ip_address = entry_data[CONF_IP_ADDRESS] - async def async_step_reauth_confirm( + # If token exists, we assume we use the v2 API and that the token has been invalidated + if entry_data.get(CONF_TOKEN): + return await self.async_step_reauth_confirm_update_token() + + # Else we assume we use the v1 API and that the API has been disabled + return await self.async_step_reauth_enable_api() + + async def async_step_reauth_enable_api( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm reauth dialog.""" + """Confirm reauth dialog, where user is asked to re-enable the HomeWizard API.""" errors: dict[str, str] | None = None if user_input is not None: reauth_entry = self._get_reauth_entry() try: - await self._async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) + await async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reauth_enable_api_successful") - return self.async_show_form(step_id="reauth_confirm", errors=errors) + return self.async_show_form(step_id="reauth_enable_api", errors=errors) + + async def async_step_reauth_confirm_update_token( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + assert self.ip_address + + errors: dict[str, str] | None = None + + token = await async_request_token(self.ip_address) + + if user_input is not None: + if token is None: + errors = {"base": "authorization_failed"} + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_TOKEN: token, + }, + ) + + return self.async_show_form( + step_id="reauth_confirm_update_token", errors=errors + ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None @@ -199,7 +274,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input: try: - device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) @@ -230,37 +305,65 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - @staticmethod - async def _async_try_connect(ip_address: str) -> Device: - """Try to connect. - Make connection with device to test the connection - and to get info for unique_id. - """ +async def async_try_connect(ip_address: str) -> Device: + """Try to connect. + + Make connection with device to test the connection + and to get info for unique_id. + """ + + energy_api: HomeWizardEnergy + + # Determine if device is v1 or v2 capable + if await has_v2_api(ip_address): + energy_api = HomeWizardEnergyV2(ip_address) + else: energy_api = HomeWizardEnergyV1(ip_address) - try: - return await energy_api.device() - except DisabledError as ex: - raise RecoverableError( - "API disabled, API must be enabled in the app", "api_not_enabled" - ) from ex + try: + return await energy_api.device() - except UnsupportedError as ex: - LOGGER.error("API version unsuppored") - raise AbortFlow("unsupported_api_version") from ex + except DisabledError as ex: + raise RecoverableError( + "API disabled, API must be enabled in the app", "api_not_enabled" + ) from ex - except RequestError as ex: - raise RecoverableError( - "Device unreachable or unexpected response", "network_error" - ) from ex + except UnsupportedError as ex: + LOGGER.error("API version unsuppored") + raise AbortFlow("unsupported_api_version") from ex - except Exception as ex: - LOGGER.exception("Unexpected exception") - raise AbortFlow("unknown_error") from ex + except RequestError as ex: + raise RecoverableError( + "Device unreachable or unexpected response", "network_error" + ) from ex - finally: - await energy_api.close() + except UnauthorizedError as ex: + raise UnauthorizedError("Unauthorized") from ex + + except Exception as ex: + LOGGER.exception("Unexpected exception") + raise AbortFlow("unknown_error") from ex + + finally: + await energy_api.close() + + +async def async_request_token(ip_address: str) -> str | None: + """Try to request a token from the device. + + This method is used to request a token from the device, + it will return None if the token request failed. + """ + + api = HomeWizardEnergyV2(ip_address) + + try: + return await api.get_token("home-assistant") + except DisabledError: + return None + finally: + await api.close() class RecoverableError(HomeAssistantError): diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index 4bed4675833..e0448edaf86 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -13,8 +13,6 @@ PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] LOGGER = logging.getLogger(__package__) # Platform config. -CONF_API_ENABLED = "api_enabled" -CONF_DATA = "data" CONF_PRODUCT_NAME = "product_name" CONF_PRODUCT_TYPE = "product_type" CONF_SERIAL = "serial" diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 7024c760b93..92beb99ad2c 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -3,11 +3,12 @@ from __future__ import annotations from homewizard_energy import HomeWizardEnergy -from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError from homewizard_energy.models import CombinedModels as DeviceResponseEntry from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL @@ -51,6 +52,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] ex, translation_domain=DOMAIN, translation_key="api_disabled" ) from ex + except UnauthorizedError as ex: + raise ConfigEntryAuthFailed from ex + self.api_disabled = False self.data = data diff --git a/homeassistant/components/homewizard/icons.json b/homeassistant/components/homewizard/icons.json index e6b1a34841f..68ebd6b84d0 100644 --- a/homeassistant/components/homewizard/icons.json +++ b/homeassistant/components/homewizard/icons.json @@ -15,6 +15,9 @@ "any_power_fail_count": { "default": "mdi:transmission-tower-off" }, + "cycles": { + "default": "mdi:battery-sync-outline" + }, "dsmr_version": { "default": "mdi:counter" }, diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 4cc94d09d74..b1a19134752 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.1.1"], - "zeroconf": ["_hwenergy._tcp.local."] + "requirements": ["python-homewizard-energy==v8.2.0"], + "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 423bc4dea49..008772a5a29 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -47,7 +47,10 @@ rules: devices: done diagnostics: done discovery-update-info: done - discovery: done + discovery: + status: done + comment: | + DHCP IP address updates are not supported for the v2 API. docs-data-update: done docs-examples: done docs-known-limitations: done @@ -66,10 +69,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: | - This integration does not raise any repairable issues. + repair-issues: done stale-devices: status: exempt comment: | diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py new file mode 100644 index 00000000000..4c9a03b493f --- /dev/null +++ b/homeassistant/components/homewizard/repairs.py @@ -0,0 +1,79 @@ +"""Repairs for HomeWizard integration.""" + +from __future__ import annotations + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .config_flow import async_request_token + + +class MigrateToV2ApiRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Create flow.""" + self.entry = entry + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + + if user_input is not None: + return await self.async_step_authorize() + + return self.async_show_form( + step_id="confirm", description_placeholders={"title": self.entry.title} + ) + + async def async_step_authorize( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the authorize step of a fix flow.""" + + ip_address = self.entry.data[CONF_IP_ADDRESS] + + # Tell device we want a token, user must now press the button within 30 seconds + # The first attempt will always fail, but this opens the window to press the button + token = await async_request_token(ip_address) + errors: dict[str, str] | None = None + + if token is None: + if user_input is not None: + errors = {"base": "authorization_failed"} + + return self.async_show_form(step_id="authorize", errors=errors) + + data = {**self.entry.data, CONF_TOKEN: token} + self.hass.config_entries.async_update_entry(self.entry, data=data) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + assert data is not None + assert isinstance(data["entry_id"], str) + + if issue_id.startswith("migrate_to_v2_api_") and ( + entry := hass.config_entries.async_get_entry(data["entry_id"]) + ): + return MigrateToV2ApiRepairFlow(entry) + + raise ValueError(f"unknown repair {issue_id}") # pragma: no cover diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 8a9738e7ae7..b6227a03bed 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Final -from homewizard_energy.models import ExternalDevice, Measurement +from homewizard_energy.models import CombinedModels, ExternalDevice from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, @@ -46,9 +46,9 @@ PARALLEL_UPDATES = 1 class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" - enabled_fn: Callable[[Measurement], bool] = lambda x: True - has_fn: Callable[[Measurement], bool] - value_fn: Callable[[Measurement], StateType] + enabled_fn: Callable[[CombinedModels], bool] = lambda x: True + has_fn: Callable[[CombinedModels], bool] + value_fn: Callable[[CombinedModels], StateType] @dataclass(frozen=True, kw_only=True) @@ -69,35 +69,43 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="smr_version", translation_key="dsmr_version", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.protocol_version is not None, - value_fn=lambda data: data.protocol_version, + has_fn=lambda data: data.measurement.protocol_version is not None, + value_fn=lambda data: data.measurement.protocol_version, ), HomeWizardSensorEntityDescription( key="meter_model", translation_key="meter_model", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.meter_model is not None, - value_fn=lambda data: data.meter_model, + has_fn=lambda data: data.measurement.meter_model is not None, + value_fn=lambda data: data.measurement.meter_model, ), HomeWizardSensorEntityDescription( key="unique_meter_id", translation_key="unique_meter_id", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.unique_id is not None, - value_fn=lambda data: data.unique_id, + has_fn=lambda data: data.measurement.unique_id is not None, + value_fn=lambda data: data.measurement.unique_id, ), HomeWizardSensorEntityDescription( key="wifi_ssid", translation_key="wifi_ssid", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.wifi_ssid is not None, - value_fn=lambda data: data.wifi_ssid, + has_fn=( + lambda data: data.system is not None and data.system.wifi_ssid is not None + ), + value_fn=( + lambda data: data.system.wifi_ssid if data.system is not None else None + ), ), HomeWizardSensorEntityDescription( key="active_tariff", translation_key="active_tariff", - has_fn=lambda data: data.tariff is not None, - value_fn=lambda data: None if data.tariff is None else str(data.tariff), + has_fn=lambda data: data.measurement.tariff is not None, + value_fn=( + lambda data: None + if data.measurement.tariff is None + else str(data.measurement.tariff) + ), device_class=SensorDeviceClass.ENUM, options=["1", "2", "3", "4"], ), @@ -108,8 +116,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_fn=lambda data: data.wifi_strength is not None, - value_fn=lambda data: data.wifi_strength, + has_fn=lambda data: data.measurement.wifi_strength is not None, + value_fn=lambda data: data.measurement.wifi_strength, ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", @@ -117,8 +125,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_kwh is not None, - value_fn=lambda data: data.energy_import_kwh, + has_fn=lambda data: data.measurement.energy_import_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", @@ -129,10 +137,10 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.energy_import_t1_kwh is not None - and data.energy_export_t2_kwh is not None + data.measurement.energy_import_t1_kwh is not None + and data.measurement.energy_export_t2_kwh is not None ), - value_fn=lambda data: data.energy_import_t1_kwh, + value_fn=lambda data: data.measurement.energy_import_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", @@ -141,8 +149,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t2_kwh is not None, - value_fn=lambda data: data.energy_import_t2_kwh, + has_fn=lambda data: data.measurement.energy_import_t2_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", @@ -151,8 +159,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t3_kwh is not None, - value_fn=lambda data: data.energy_import_t3_kwh, + has_fn=lambda data: data.measurement.energy_import_t3_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", @@ -161,8 +169,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t4_kwh is not None, - value_fn=lambda data: data.energy_import_t4_kwh, + has_fn=lambda data: data.measurement.energy_import_t4_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t4_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", @@ -170,9 +178,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_kwh is not None, - enabled_fn=lambda data: data.energy_export_kwh != 0, - value_fn=lambda data: data.energy_export_kwh, + has_fn=lambda data: data.measurement.energy_export_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", @@ -183,11 +191,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.energy_export_t1_kwh is not None - and data.energy_export_t2_kwh is not None + data.measurement.energy_export_t1_kwh is not None + and data.measurement.energy_export_t2_kwh is not None ), - enabled_fn=lambda data: data.energy_export_t1_kwh != 0, - value_fn=lambda data: data.energy_export_t1_kwh, + enabled_fn=lambda data: data.measurement.energy_export_t1_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", @@ -196,9 +204,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t2_kwh is not None, - enabled_fn=lambda data: data.energy_export_t2_kwh != 0, - value_fn=lambda data: data.energy_export_t2_kwh, + has_fn=lambda data: data.measurement.energy_export_t2_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t2_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", @@ -207,9 +215,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t3_kwh is not None, - enabled_fn=lambda data: data.energy_export_t3_kwh != 0, - value_fn=lambda data: data.energy_export_t3_kwh, + has_fn=lambda data: data.measurement.energy_export_t3_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t3_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", @@ -218,9 +226,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t4_kwh is not None, - enabled_fn=lambda data: data.energy_export_t4_kwh != 0, - value_fn=lambda data: data.energy_export_t4_kwh, + has_fn=lambda data: data.measurement.energy_export_t4_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t4_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t4_kwh, ), HomeWizardSensorEntityDescription( key="active_power_w", @@ -228,8 +236,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_w is not None, - value_fn=lambda data: data.power_w, + has_fn=lambda data: data.measurement.power_w is not None, + value_fn=lambda data: data.measurement.power_w, ), HomeWizardSensorEntityDescription( key="active_power_l1_w", @@ -239,8 +247,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l1_w is not None, - value_fn=lambda data: data.power_l1_w, + has_fn=lambda data: data.measurement.power_l1_w is not None, + value_fn=lambda data: data.measurement.power_l1_w, ), HomeWizardSensorEntityDescription( key="active_power_l2_w", @@ -250,8 +258,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l2_w is not None, - value_fn=lambda data: data.power_l2_w, + has_fn=lambda data: data.measurement.power_l2_w is not None, + value_fn=lambda data: data.measurement.power_l2_w, ), HomeWizardSensorEntityDescription( key="active_power_l3_w", @@ -261,8 +269,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l3_w is not None, - value_fn=lambda data: data.power_l3_w, + has_fn=lambda data: data.measurement.power_l3_w is not None, + value_fn=lambda data: data.measurement.power_l3_w, ), HomeWizardSensorEntityDescription( key="active_voltage_v", @@ -270,8 +278,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_v is not None, - value_fn=lambda data: data.voltage_v, + has_fn=lambda data: data.measurement.voltage_v is not None, + value_fn=lambda data: data.measurement.voltage_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", @@ -281,8 +289,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l1_v is not None, - value_fn=lambda data: data.voltage_l1_v, + has_fn=lambda data: data.measurement.voltage_l1_v is not None, + value_fn=lambda data: data.measurement.voltage_l1_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l2_v", @@ -292,8 +300,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l2_v is not None, - value_fn=lambda data: data.voltage_l2_v, + has_fn=lambda data: data.measurement.voltage_l2_v is not None, + value_fn=lambda data: data.measurement.voltage_l2_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l3_v", @@ -303,8 +311,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l3_v is not None, - value_fn=lambda data: data.voltage_l3_v, + has_fn=lambda data: data.measurement.voltage_l3_v is not None, + value_fn=lambda data: data.measurement.voltage_l3_v, ), HomeWizardSensorEntityDescription( key="active_current_a", @@ -312,8 +320,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_a is not None, - value_fn=lambda data: data.current_a, + has_fn=lambda data: data.measurement.current_a is not None, + value_fn=lambda data: data.measurement.current_a, ), HomeWizardSensorEntityDescription( key="active_current_l1_a", @@ -323,8 +331,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l1_a is not None, - value_fn=lambda data: data.current_l1_a, + has_fn=lambda data: data.measurement.current_l1_a is not None, + value_fn=lambda data: data.measurement.current_l1_a, ), HomeWizardSensorEntityDescription( key="active_current_l2_a", @@ -334,8 +342,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l2_a is not None, - value_fn=lambda data: data.current_l2_a, + has_fn=lambda data: data.measurement.current_l2_a is not None, + value_fn=lambda data: data.measurement.current_l2_a, ), HomeWizardSensorEntityDescription( key="active_current_l3_a", @@ -345,8 +353,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l3_a is not None, - value_fn=lambda data: data.current_l3_a, + has_fn=lambda data: data.measurement.current_l3_a is not None, + value_fn=lambda data: data.measurement.current_l3_a, ), HomeWizardSensorEntityDescription( key="active_frequency_hz", @@ -354,8 +362,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.frequency_hz is not None, - value_fn=lambda data: data.frequency_hz, + has_fn=lambda data: data.measurement.frequency_hz is not None, + value_fn=lambda data: data.measurement.frequency_hz, ), HomeWizardSensorEntityDescription( key="active_apparent_power_va", @@ -363,8 +371,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_va is not None, - value_fn=lambda data: data.apparent_power_va, + has_fn=lambda data: data.measurement.apparent_power_va is not None, + value_fn=lambda data: data.measurement.apparent_power_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l1_va", @@ -374,8 +382,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l1_va is not None, - value_fn=lambda data: data.apparent_power_l1_va, + has_fn=lambda data: data.measurement.apparent_power_l1_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l1_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l2_va", @@ -385,8 +393,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l2_va is not None, - value_fn=lambda data: data.apparent_power_l2_va, + has_fn=lambda data: data.measurement.apparent_power_l2_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l2_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l3_va", @@ -396,8 +404,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l3_va is not None, - value_fn=lambda data: data.apparent_power_l3_va, + has_fn=lambda data: data.measurement.apparent_power_l3_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l3_va, ), HomeWizardSensorEntityDescription( key="active_reactive_power_var", @@ -405,8 +413,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_var is not None, - value_fn=lambda data: data.reactive_power_var, + has_fn=lambda data: data.measurement.reactive_power_var is not None, + value_fn=lambda data: data.measurement.reactive_power_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l1_var", @@ -416,8 +424,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l1_var is not None, - value_fn=lambda data: data.reactive_power_l1_var, + has_fn=lambda data: data.measurement.reactive_power_l1_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l1_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l2_var", @@ -427,8 +435,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l2_var is not None, - value_fn=lambda data: data.reactive_power_l2_var, + has_fn=lambda data: data.measurement.reactive_power_l2_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l2_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l3_var", @@ -438,8 +446,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l3_var is not None, - value_fn=lambda data: data.reactive_power_l3_var, + has_fn=lambda data: data.measurement.reactive_power_l3_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l3_var, ), HomeWizardSensorEntityDescription( key="active_power_factor", @@ -447,8 +455,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor is not None, - value_fn=lambda data: to_percentage(data.power_factor), + has_fn=lambda data: data.measurement.power_factor is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor), ), HomeWizardSensorEntityDescription( key="active_power_factor_l1", @@ -458,8 +466,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l1 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l1), + has_fn=lambda data: data.measurement.power_factor_l1 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l1), ), HomeWizardSensorEntityDescription( key="active_power_factor_l2", @@ -469,8 +477,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l2 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l2), + has_fn=lambda data: data.measurement.power_factor_l2 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l2), ), HomeWizardSensorEntityDescription( key="active_power_factor_l3", @@ -480,94 +488,94 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l3 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l3), + has_fn=lambda data: data.measurement.power_factor_l3 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l3), ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l1_count is not None, - value_fn=lambda data: data.voltage_sag_l1_count, + has_fn=lambda data: data.measurement.voltage_sag_l1_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l2_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l2_count is not None, - value_fn=lambda data: data.voltage_sag_l2_count, + has_fn=lambda data: data.measurement.voltage_sag_l2_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l3_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l3_count is not None, - value_fn=lambda data: data.voltage_sag_l3_count, + has_fn=lambda data: data.measurement.voltage_sag_l3_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l3_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l1_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l1_count is not None, - value_fn=lambda data: data.voltage_swell_l1_count, + has_fn=lambda data: data.measurement.voltage_swell_l1_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l2_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l2_count is not None, - value_fn=lambda data: data.voltage_swell_l2_count, + has_fn=lambda data: data.measurement.voltage_swell_l2_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l3_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l3_count is not None, - value_fn=lambda data: data.voltage_swell_l3_count, + has_fn=lambda data: data.measurement.voltage_swell_l3_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l3_count, ), HomeWizardSensorEntityDescription( key="any_power_fail_count", translation_key="any_power_fail_count", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.any_power_fail_count is not None, - value_fn=lambda data: data.any_power_fail_count, + has_fn=lambda data: data.measurement.any_power_fail_count is not None, + value_fn=lambda data: data.measurement.any_power_fail_count, ), HomeWizardSensorEntityDescription( key="long_power_fail_count", translation_key="long_power_fail_count", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.long_power_fail_count is not None, - value_fn=lambda data: data.long_power_fail_count, + has_fn=lambda data: data.measurement.long_power_fail_count is not None, + value_fn=lambda data: data.measurement.long_power_fail_count, ), HomeWizardSensorEntityDescription( key="active_power_average_w", translation_key="active_power_average_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.average_power_15m_w is not None, - value_fn=lambda data: data.average_power_15m_w, + has_fn=lambda data: data.measurement.average_power_15m_w is not None, + value_fn=lambda data: data.measurement.average_power_15m_w, ), HomeWizardSensorEntityDescription( key="monthly_power_peak_w", translation_key="monthly_power_peak_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.monthly_power_peak_w is not None, - value_fn=lambda data: data.monthly_power_peak_w, + has_fn=lambda data: data.measurement.monthly_power_peak_w is not None, + value_fn=lambda data: data.measurement.monthly_power_peak_w, ), HomeWizardSensorEntityDescription( key="active_liter_lpm", translation_key="active_liter_lpm", native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, - has_fn=lambda data: data.active_liter_lpm is not None, - value_fn=lambda data: data.active_liter_lpm, + has_fn=lambda data: data.measurement.active_liter_lpm is not None, + value_fn=lambda data: data.measurement.active_liter_lpm, ), HomeWizardSensorEntityDescription( key="total_liter_m3", @@ -575,8 +583,26 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_liter_m3 is not None, - value_fn=lambda data: data.total_liter_m3, + has_fn=lambda data: data.measurement.total_liter_m3 is not None, + value_fn=lambda data: data.measurement.total_liter_m3, + ), + HomeWizardSensorEntityDescription( + key="state_of_charge_pct", + translation_key="state_of_charge_pct", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + has_fn=lambda data: data.measurement.state_of_charge_pct is not None, + value_fn=lambda data: data.measurement.state_of_charge_pct, + ), + HomeWizardSensorEntityDescription( + key="cycles", + translation_key="cycles", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + has_fn=lambda data: data.measurement.cycles is not None, + value_fn=lambda data: data.measurement.cycles, ), ) @@ -622,16 +648,15 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" - measurement = entry.runtime_data.data.measurement - # Initialize default sensors entities: list = [ HomeWizardSensorEntity(entry.runtime_data, description) for description in SENSORS - if description.has_fn(measurement) + if description.has_fn(entry.runtime_data.data) ] # Initialize external devices + measurement = entry.runtime_data.data.measurement if measurement.external_devices is not None: for unique_id, device in measurement.external_devices.items(): if device.type is not None and ( @@ -661,13 +686,13 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" - if not description.enabled_fn(self.coordinator.data.measurement): + if not description.enabled_fn(self.coordinator.data): self._attr_entity_registry_enabled_default = False @property def native_value(self) -> StateType: """Return the sensor value.""" - return self.entity_description.value_fn(self.coordinator.data.measurement) + return self.entity_description.value_fn(self.coordinator.data) @property def available(self) -> bool: diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 4309664c4c8..dbaef8439d9 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -15,9 +15,17 @@ "title": "Confirm", "description": "Do you want to set up {product_type} ({serial}) at {ip_address}?" }, - "reauth_confirm": { + "reauth_enable_api": { "description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings." }, + "reauth_confirm_update_token": { + "title": "Re-authenticate", + "description": "[%key:component::homewizard::config::step::authorize::description%]" + }, + "authorize": { + "title": "Authorize", + "description": "Press the button on the HomeWizard Energy device, then select the button below." + }, "reconfigure": { "description": "Update configuration for {title}.", "data": { @@ -30,7 +38,8 @@ }, "error": { "api_not_enabled": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings.", - "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network" + "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network", + "authorization_failed": "Failed to authorize, make sure to press the button of the device within 30 seconds" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -38,7 +47,8 @@ "device_not_supported": "This device is not supported", "unknown_error": "[%key:common::config_flow::error::unknown%]", "unsupported_api_version": "Detected unsupported API version", - "reauth_successful": "Enabling API was successful", + "reauth_enable_api_successful": "Enabling API was successful", + "reauth_successful": "Authorization successful", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "wrong_device": "The configured device is not the same found on this IP address." } @@ -121,6 +131,12 @@ }, "total_liter_m3": { "name": "Total water usage" + }, + "cycles": { + "name": "Battery cycles" + }, + "state_of_charge_pct": { + "name": "State of charge" } }, "switch": { @@ -139,5 +155,26 @@ "communication_error": { "message": "An error occurred while communicating with HomeWizard device" } + }, + "issues": { + "migrate_to_v2_api": { + "title": "Update authentication method", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::homewizard::issues::migrate_to_v2_api::title%]", + "description": "Your {title} now supports a more secure and feature-rich communication method. To take advantage of this, you need to reconfigure the integration.\n\nSelect **Submit** to start the reconfiguration." + }, + "authorize": { + "title": "[%key:component::homewizard::config::step::authorize::title%]", + "description": "[%key:component::homewizard::config::step::authorize::description%]" + } + }, + "error": { + "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + } + } + } } } diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 0878703e4d5..8ebb56433b1 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -59,7 +59,7 @@ SWITCHES = [ key="cloud_connection", translation_key="cloud_connection", entity_category=EntityCategory.CONFIG, - create_fn=lambda _: True, + create_fn=lambda x: x.device.supports_cloud_enable(), available_fn=lambda x: x.system is not None, is_on_fn=lambda x: x.system.cloud_enabled if x.system else None, set_fn=lambda api, active: api.system(cloud_enabled=active), diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 203f01e7d68..be15d88aec2 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -522,6 +522,11 @@ ZEROCONF = { "domain": "homekit", }, ], + "_homewizard._tcp.local.": [ + { + "domain": "homewizard", + }, + ], "_hscp._tcp.local.": [ { "domain": "apple_tv", diff --git a/requirements_all.txt b/requirements_all.txt index 287ca9364a5..57c534d0e2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2388,7 +2388,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.1.1 +python-homewizard-energy==v8.2.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7220be9718..c4aa58667c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1933,7 +1933,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.1.1 +python-homewizard-energy==v8.2.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index b540ebac91a..f9c5e617904 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -3,11 +3,18 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.models import CombinedModels, Device, Measurement, State, System +from homewizard_energy.models import ( + CombinedModels, + Device, + Measurement, + State, + System, + Token, +) import pytest from homeassistant.components.homewizard.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, get_fixture_path, load_json_object_fixture @@ -65,6 +72,59 @@ def mock_homewizardenergy( yield client +@pytest.fixture +def mock_homewizardenergy_v2( + device_fixture: str, +) -> MagicMock: + """Return a mock bridge.""" + with ( + patch( + "homeassistant.components.homewizard.HomeWizardEnergyV2", + autospec=True, + ) as homewizard, + patch( + "homeassistant.components.homewizard.config_flow.HomeWizardEnergyV2", + new=homewizard, + ), + ): + client = homewizard.return_value + + client.combined.return_value = CombinedModels( + device=Device.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/device.json", DOMAIN) + ), + measurement=Measurement.from_dict( + load_json_object_fixture( + f"v2/{device_fixture}/measurement.json", DOMAIN + ) + ), + state=( + State.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/state.json", DOMAIN) + ) + if get_fixture_path(f"v2/{device_fixture}/state.json", DOMAIN).exists() + else None + ), + system=( + System.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/system.json", DOMAIN) + ) + if get_fixture_path(f"v2/{device_fixture}/system.json", DOMAIN).exists() + else None + ), + ) + + # device() call is used during configuration flow + client.device.return_value = client.combined.return_value.device + + # Authorization flow is used during configuration flow + client.get_token.return_value = Token.from_dict( + load_json_object_fixture("v2/generic/token.json", DOMAIN) + ).token + + yield client + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -90,6 +150,20 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_v2() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Device", + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", + }, + unique_id="HWE-P1_5c2fafabcdef", + ) + + @pytest.fixture async def init_integration( hass: HomeAssistant, diff --git a/tests/components/homewizard/fixtures/HWE-BAT/data.json b/tests/components/homewizard/fixtures/HWE-BAT/data.json new file mode 100644 index 00000000000..490120e7ffd --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/data.json @@ -0,0 +1,12 @@ +{ + "wifi_ssid": "simulating v1 support", + "wifi_strength": 100, + "total_power_import_kwh": 123.456, + "total_power_export_kwh": 123.456, + "active_power_w": 123, + "active_voltage_v": 230, + "active_current_a": 1.5, + "active_frequency_hz": 50, + "state_of_charge_pct": 50, + "cycles": 123 +} diff --git a/tests/components/homewizard/fixtures/HWE-BAT/device.json b/tests/components/homewizard/fixtures/HWE-BAT/device.json new file mode 100644 index 00000000000..c551dc34c91 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-BAT", + "product_name": "Plug-In Battery", + "serial": "5c2fafabcdef", + "firmware_version": "1.00", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-BAT/system.json b/tests/components/homewizard/fixtures/HWE-BAT/system.json new file mode 100644 index 00000000000..b4094f497cb --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/system.json @@ -0,0 +1,7 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "status_led_brightness_pct": 100 +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/device.json b/tests/components/homewizard/fixtures/v2/HWE-P1/device.json new file mode 100644 index 00000000000..2dc3f0692a2 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "5c2fafabcdef", + "firmware_version": "4.19", + "api_version": "2.0.0" +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json b/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json new file mode 100644 index 00000000000..2004b0cd37f --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json @@ -0,0 +1,48 @@ +{ + "protocol_version": 50, + "meter_model": "ISKRA 2M550T-101", + "unique_id": "4E6576657220476F6E6E61204C657420596F7520446F776E", + "timestamp": "2024-06-28T14:12:34", + "tariff": 2, + "energy_import_kwh": 13779.338, + "energy_import_t1_kwh": 10830.511, + "energy_import_t2_kwh": 2948.827, + "energy_export_kwh": 1234.567, + "energy_export_t1_kwh": 234.567, + "energy_export_t2_kwh": 1000, + "power_w": -543, + "power_l1_w": -676, + "power_l2_w": 133, + "power_l3_w": 0, + "current_a": 6, + "current_l1_a": -4, + "current_l2_a": 2, + "current_l3_a": 0, + "voltage_sag_l1_count": 1, + "voltage_sag_l2_count": 1, + "voltage_sag_l3_count": 0, + "voltage_swell_l1_count": 0, + "voltage_swell_l2_count": 0, + "voltage_swell_l3_count": 0, + "any_power_fail_count": 4, + "long_power_fail_count": 5, + "average_power_15m_w": 123.0, + "monthly_power_peak_w": 1111.0, + "monthly_power_peak_timestamp": "2024-06-04T10:11:22", + "external": [ + { + "unique_id": "4E6576657220676F6E6E612072756E2061726F756E64", + "type": "gas_meter", + "timestamp": "2024-06-28T14:00:00", + "value": 2569.646, + "unit": "m3" + }, + { + "unique_id": "616E642064657365727420796F75", + "type": "water_meter", + "timestamp": "2024-06-28T14:05:00", + "value": 123.456, + "unit": "m3" + } + ] +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/system.json b/tests/components/homewizard/fixtures/v2/HWE-P1/system.json new file mode 100644 index 00000000000..38bcaeeb584 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/system.json @@ -0,0 +1,8 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "status_led_brightness_pct": 100, + "api_v1_enabled": true +} diff --git a/tests/components/homewizard/fixtures/v2/generic/token.json b/tests/components/homewizard/fixtures/v2/generic/token.json new file mode 100644 index 00000000000..8fa1e9cb8d1 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/generic/token.json @@ -0,0 +1,4 @@ +{ + "token": "00112233445566778899aabbccddeeff", + "name": "local/new_user" +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index b8cf98d9211..192b9dbdc32 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -1,9 +1,99 @@ # serializer version: 1 +# name: test_diagnostics[HWE-BAT] + dict({ + 'data': dict({ + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '1.00', + 'id': '**REDACTED**', + 'model_name': 'Plug-In Battery', + 'product_name': 'Plug-In Battery', + 'product_type': 'HWE-BAT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ + 'active_liter_lpm': None, + 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': 1.5, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': 123, + 'energy_export_kwh': 123.456, + 'energy_export_t1_kwh': None, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 123.456, + 'energy_import_t1_kwh': None, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, + 'external_devices': None, + 'frequency_hz': 50.0, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': None, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 123.0, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': 50.0, + 'tariff': None, + 'timestamp': None, + 'total_liter_m3': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'voltage_v': 230.0, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'state': None, + 'system': dict({ + 'api_v1_enabled': None, + 'cloud_enabled': False, + 'status_led_brightness_pct': 100, + 'uptime_s': 356, + 'wifi_rssi_db': -77, + 'wifi_ssid': '**REDACTED**', + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + }) +# --- # name: test_diagnostics[HWE-KWH1] dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 1-phase', @@ -93,7 +183,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 3-phase', @@ -183,7 +273,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '4.19', 'id': '**REDACTED**', 'model_name': 'Wi-Fi P1 Meter', @@ -309,7 +399,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.03', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Energy Socket', @@ -403,7 +493,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '4.07', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Energy Socket', @@ -497,7 +587,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '2.03', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Watermeter', @@ -587,7 +677,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 1-phase', @@ -677,7 +767,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 3-phase', diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 31a949ca7bd..df445a9ddca 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1,4 +1,704 @@ # serializer version: 1 +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_battery_cycles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery cycles', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cycles', + 'unique_id': 'HWE-P1_5c2fafabcdef_cycles', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Battery cycles', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.device_battery_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_charge_pct', + 'unique_id': 'HWE-P1_5c2fafabcdef_state_of_charge_pct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Device State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index b2ae7bd45e0..c39853c3f9a 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -1,15 +1,20 @@ """Test the homewizard config flow.""" from ipaddress import ip_address -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +from homewizard_energy.errors import ( + DisabledError, + RequestError, + UnauthorizedError, + UnsupportedError, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.homewizard.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -225,10 +230,10 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No type="", name="", properties={ - # "api_enabled": "1", --> removed + "api_enabled": "1", "path": "/api/v1", "product_name": "P1 meter", - "product_type": "HWE-P1", + # "product_type": "HWE-P1", --> removed "serial": "5c2fafabcdef", }, ), @@ -238,32 +243,6 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No assert result["reason"] == "invalid_discovery_parameters" -async def test_discovery_invalid_api(hass: HomeAssistant) -> None: - """Test discovery detecting invalid_api.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - "api_enabled": "1", - "path": "/api/not_v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "5c2fafabcdef", - }, - ), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_api_version" - - async def test_dhcp_discovery_updates_entry( hass: HomeAssistant, mock_homewizardenergy: MagicMock, @@ -338,6 +317,32 @@ async def test_dhcp_discovery_ignores_unknown( assert result.get("reason") == "unknown" +async def test_dhcp_discovery_aborts_for_v2_api( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery aborts when v2 API is detected. + + DHCP discovery requires authorization which is not yet implemented + """ + mock_homewizardenergy.device.side_effect = UnauthorizedError + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.0.0.127", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unsupported_api_version" + + async def test_discovery_flow_updates_new_ip( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -455,12 +460,12 @@ async def test_reauth_flow( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "reauth_enable_api" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["reason"] == "reauth_enable_api_successful" async def test_reauth_error( @@ -475,7 +480,7 @@ async def test_reauth_error( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "reauth_enable_api" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -609,3 +614,222 @@ async def test_reconfigure_cannot_connect( # changed entry assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" + + +### TESTS FOR V2 IMPLEMENTATION ### + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_flow_works_with_v2_api_support( + hass: HomeAssistant, + mock_homewizardenergy_v2: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow accepts user configuration and triggers authorization when detected v2 support.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate v2 support but not authorized + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + # Simulate user authorizing + mock_homewizardenergy_v2.device.side_effect = None + mock_homewizardenergy_v2.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_flow_detects_failed_user_authorization( + hass: HomeAssistant, + mock_homewizardenergy_v2: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow accepts user configuration and detects failed button press by user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate v2 support but not authorized + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + assert result["errors"] == {"base": "authorization_failed"} + + # Restore normal functionality + mock_homewizardenergy_v2.device.side_effect = None + mock_homewizardenergy_v2.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_updates_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test reauth flow token is updated.""" + + mock_config_entry_v2.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_v2.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm_update_token" + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.return_value = "cool_new_token" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify that the token was updated + await hass.async_block_till_done() + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data.get(CONF_TOKEN) + == "cool_new_token" + ) + assert len(mock_setup_entry.mock_calls) == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_handles_user_not_pressing_button( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test reauth flow token is updated.""" + + mock_config_entry_v2.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_v2.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm_update_token" + assert result["errors"] is None + + # Simulate button not being pressed + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "authorization_failed"} + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_new_token" + + # Successful reauth + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify that the token was updated + await hass.async_block_till_done() + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data.get(CONF_TOKEN) + == "cool_new_token" + ) + assert len(mock_setup_entry.mock_calls) == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_with_v2_api_ask_authorization( + hass: HomeAssistant, + # mock_setup_entry: AsyncMock, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test discovery detecting missing discovery info.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=443, + hostname="p1meter-abcdef.local.", + type="", + name="", + properties={ + "api_version": "2.0.0", + "id": "appliance/p1dongle/5c2fafabcdef", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "5c2fafabcdef", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_token" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == "cool_token" diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index e3d7f4e6da9..c7063d497c3 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -21,6 +21,7 @@ from tests.typing import ClientSessionGenerator "SDM630", "HWE-KWH1", "HWE-KWH3", + "HWE-BAT", ], ) async def test_diagnostics( diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index ed4bad8b2e8..77366da84c5 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from homewizard_energy.errors import DisabledError +from homewizard_energy.errors import DisabledError, UnauthorizedError import pytest from homeassistant.components.homewizard.const import DOMAIN @@ -14,12 +14,12 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed -async def test_load_unload( +async def test_load_unload_v1( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homewizardenergy: MagicMock, ) -> None: - """Test loading and unloading of integration.""" + """Test loading and unloading of integration with v1 config.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -33,6 +33,25 @@ async def test_load_unload( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +async def test_load_unload_v2( + hass: HomeAssistant, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test loading and unloading of integration with v2 config.""" + mock_config_entry_v2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.LOADED + assert len(mock_homewizardenergy_v2.combined.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.NOT_LOADED + + async def test_load_failed_host_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -64,7 +83,7 @@ async def test_load_detect_api_disabled( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" + assert flow.get("step_id") == "reauth_enable_api" assert flow.get("handler") == DOMAIN assert "context" in flow @@ -72,6 +91,31 @@ async def test_load_detect_api_disabled( assert flow["context"].get("entry_id") == mock_config_entry.entry_id +async def test_load_detect_invalid_token( + hass: HomeAssistant, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test setup detects invalid token.""" + mock_homewizardenergy_v2.combined.side_effect = UnauthorizedError() + mock_config_entry_v2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm_update_token" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry_v2.entry_id + + @pytest.mark.usefixtures("mock_homewizardenergy") async def test_load_removes_reauth_flow( hass: HomeAssistant, @@ -128,5 +172,5 @@ async def test_disablederror_reloads_integration( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" + assert flow.get("step_id") == "reauth_enable_api" assert flow.get("handler") == DOMAIN diff --git a/tests/components/homewizard/test_repair.py b/tests/components/homewizard/test_repair.py new file mode 100644 index 00000000000..a59d6f415dd --- /dev/null +++ b/tests/components/homewizard/test_repair.py @@ -0,0 +1,82 @@ +"""Test the homewizard config flow.""" + +from unittest.mock import MagicMock, patch + +from homewizard_energy.errors import DisabledError + +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +async def test_repair_acquires_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, + mock_homewizardenergy_v2: MagicMock, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair flow is able to obtain and use token.""" + + assert await async_setup_component(hass, "repairs", {}) + await async_process_repairs_platforms(hass) + client = await hass_client() + + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.homewizard.has_v2_api", return_value=True): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Get active repair flow + issue_id = f"migrate_to_v2_api_{mock_config_entry.entry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + + assert issue.data.get("entry_id") == mock_config_entry.entry_id + + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "authorize" + + # Simulate user not pressing the button + result = await process_repair_fix_flow(client, flow_id, json={}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "authorize" + assert result["errors"] == {"base": "authorization_failed"} + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_token" + result = await process_repair_fix_flow(client, flow_id, json={}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_config_entry.data[CONF_TOKEN] == "cool_token" + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 128a3de2ebf..c1474c4b947 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -291,6 +291,19 @@ pytestmark = [ "sensor.water_meter_water", ], ), + ( + "HWE-BAT", + [ + "sensor.device_battery_cycles", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power", + "sensor.device_state_of_charge", + "sensor.device_voltage", + ], + ), ], ) async def test_sensors( @@ -431,6 +444,14 @@ async def test_sensors( "sensor.device_wi_fi_strength", ], ), + ( + "HWE-BAT", + [ + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_voltage", + ], + ), ], ) async def test_disabled_by_default_sensors( @@ -492,6 +513,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_apparent_power", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -521,6 +543,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -543,6 +566,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_2", "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -568,6 +592,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -590,6 +615,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_apparent_power", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -623,6 +649,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", @@ -644,6 +671,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -670,6 +698,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -688,6 +717,7 @@ async def test_external_sensors_unreachable( "SDM630", [ "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -706,6 +736,7 @@ async def test_external_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -729,6 +760,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -755,6 +787,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -773,6 +806,7 @@ async def test_external_sensors_unreachable( "HWE-KWH3", [ "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -791,6 +825,7 @@ async def test_external_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -806,6 +841,54 @@ async def test_external_sensors_unreachable( "sensor.device_water_usage", ], ), + ( + "HWE-BAT", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_dsmr_version", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_factor", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_1", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_water_usage", + ], + ), ], ) async def test_entities_not_created_for_device(