From 073e72e6d3437fa03efa643d0d66f04f42eafd7c Mon Sep 17 00:00:00 2001 From: bestycame Date: Wed, 18 Jun 2025 11:36:30 +0000 Subject: [PATCH 01/26] Add Hanna integration --- CODEOWNERS | 2 + homeassistant/components/hanna/__init__.py | 73 +++++ homeassistant/components/hanna/config_flow.py | 101 ++++++ homeassistant/components/hanna/const.py | 6 + homeassistant/components/hanna/coordinator.py | 174 +++++++++++ homeassistant/components/hanna/manifest.json | 11 + .../components/hanna/quality_scale.yaml | 79 +++++ homeassistant/components/hanna/sensor.py | 292 ++++++++++++++++++ homeassistant/components/hanna/strings.json | 30 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 7 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/hanna/__init__.py | 1 + tests/components/hanna/conftest.py | 43 +++ tests/components/hanna/test_config_flow.py | 86 ++++++ 16 files changed, 912 insertions(+) create mode 100644 homeassistant/components/hanna/__init__.py create mode 100644 homeassistant/components/hanna/config_flow.py create mode 100644 homeassistant/components/hanna/const.py create mode 100644 homeassistant/components/hanna/coordinator.py create mode 100644 homeassistant/components/hanna/manifest.json create mode 100644 homeassistant/components/hanna/quality_scale.yaml create mode 100644 homeassistant/components/hanna/sensor.py create mode 100644 homeassistant/components/hanna/strings.json create mode 100644 tests/components/hanna/__init__.py create mode 100644 tests/components/hanna/conftest.py create mode 100644 tests/components/hanna/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 3f3ce07ce8479..d6cd9065dac72 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -605,6 +605,8 @@ build.json @home-assistant/supervisor /tests/components/guardian/ @bachya /homeassistant/components/habitica/ @tr4nt0r /tests/components/habitica/ @tr4nt0r +/homeassistant/components/hanna/ @bestycame +/tests/components/hanna/ @bestycame /homeassistant/components/hardkernel/ @home-assistant/core /tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core diff --git a/homeassistant/components/hanna/__init__.py b/homeassistant/components/hanna/__init__.py new file mode 100644 index 0000000000000..cb81a87d9a5c1 --- /dev/null +++ b/homeassistant/components/hanna/__init__.py @@ -0,0 +1,73 @@ +"""The Hanna Instruments integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_validation import config_entry_only_config_schema +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_integration + +from .const import DOMAIN +from .coordinator import HannaDataCoordinator, HannaMainCoordinator + +PLATFORMS = [Platform.SENSOR] + +CONFIG_SCHEMA = config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Hanna Instruments component.""" + _ = await async_get_integration(hass, DOMAIN) + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Hanna Instruments from a config entry.""" + + # Create main coordinator + main_coordinator = HannaMainCoordinator(hass, entry) + await main_coordinator.async_authenticate( + entry.data["email"], entry.data["password"], entry.data["code"] + ) + await main_coordinator.async_config_entry_first_refresh() + + # Create device coordinators + devices = await main_coordinator.async_get_devices() + device_coordinators = {} + for device in devices: + coordinator = HannaDataCoordinator(hass, main_coordinator, device, entry) + await coordinator.async_config_entry_first_refresh() + device_coordinators[coordinator.device_identifier] = coordinator + + # Set runtime data + entry.runtime_data = { + "main_coordinator": main_coordinator, + "device_coordinators": device_coordinators, + } + + # Forward the setup to the platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + # Unload platforms + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + # Clean up coordinators + if unload_ok and entry.runtime_data: + # Clean up device coordinators + for coordinator in entry.runtime_data["device_coordinators"].values(): + await coordinator.async_shutdown() + + # Clean up main coordinator + await entry.runtime_data["main_coordinator"].async_shutdown() + + # Clear runtime data + entry.runtime_data = None + + return unload_ok diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py new file mode 100644 index 0000000000000..def5f9a400314 --- /dev/null +++ b/homeassistant/components/hanna/config_flow.py @@ -0,0 +1,101 @@ +"""Config flow for Hanna Instruments integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from hanna_cloud import HannaCloudClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD, CONF_SCAN_INTERVAL + +from .const import DEFAULT_ENCRYPTION_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class HannaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hanna Instruments.""" + + VERSION = 1 + data_schema = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_CODE, default=DEFAULT_ENCRYPTION_KEY): str, + vol.Required(CONF_SCAN_INTERVAL, default=5): int, + } + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=self.data_schema, + errors=errors, + ) + + try: + client = HannaCloudClient() + await self.hass.async_add_executor_job( + client.authenticate, + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + user_input[CONF_CODE], + ) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "invalid_auth" + return self.async_show_form( + step_id="user", + data_schema=self.data_schema, + errors=errors, + ) + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data=user_input, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="reconfigure", + data_schema=self.data_schema, + errors=errors, + ) + + try: + client = HannaCloudClient() + await self.hass.async_add_executor_job( + client.authenticate, + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + user_input[CONF_CODE], + ) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "invalid_auth" + return self.async_show_form( + step_id="reconfigure", + data_schema=self.data_schema, + errors=errors, + ) + + # Update the existing entry + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + data=user_input, + ) diff --git a/homeassistant/components/hanna/const.py b/homeassistant/components/hanna/const.py new file mode 100644 index 0000000000000..6de11935b0fc9 --- /dev/null +++ b/homeassistant/components/hanna/const.py @@ -0,0 +1,6 @@ +"""Constants for the Hanna integration.""" + +DOMAIN = "hanna" + +# This key is NOT private. It is found in the JavaScript code of the Hanna Cloud webapp at https://www.hannacloud.com +DEFAULT_ENCRYPTION_KEY = "MzJmODBmMDU0ZTAyNDFjYWM0YTVhOGQxY2ZlZTkwMDM=" diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py new file mode 100644 index 0000000000000..a246cdc113c29 --- /dev/null +++ b/homeassistant/components/hanna/coordinator.py @@ -0,0 +1,174 @@ +"""Hanna Instruments data coordinator for Home Assistant. + +This module provides the data coordinator for fetching and managing Hanna Instruments +sensor data. +""" + +from datetime import datetime, timedelta +import logging +from typing import Any + +from hanna_cloud import HannaCloudClient +from requests.exceptions import RequestException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +class HannaMainCoordinator(DataUpdateCoordinator): + """Main coordinator for Hanna Instruments authentication and device management.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> None: + """Initialize the main Hanna coordinator.""" + self.api_client = HannaCloudClient() + self._devices: dict[str, dict] = {} + update_interval = timedelta(minutes=config_entry.data.get("update_interval", 1)) + super().__init__( + hass, + _LOGGER, + name="hanna_main", + update_interval=update_interval, + ) + + async def async_authenticate(self, email: str, password: str, code: str) -> None: + """Authenticate with the Hanna API.""" + await self.hass.async_add_executor_job( + self.api_client.authenticate, email, password, code + ) + + async def async_get_devices(self) -> list[dict]: + """Get all devices associated with the account.""" + devices = await self.hass.async_add_executor_job(self.api_client.get_devices) + self._devices = {device.get("DID"): device for device in devices} + return devices + + def get_device(self, device_id: str) -> dict: + """Get a specific device by ID.""" + return self._devices[device_id] + + async def _async_update_data(self): + """Update the list of devices.""" + try: + await self.async_get_devices() + except Exception as e: + _LOGGER.error("Error updating devices: %s", e) + raise UpdateFailed(f"Error updating devices: {e}") from e + else: + return self._devices + + +class HannaDataCoordinator(DataUpdateCoordinator): + """Coordinator for fetching Hanna sensor data.""" + + def __init__( + self, + hass: HomeAssistant, + main_coordinator: HannaMainCoordinator, + device: dict, + config_entry: ConfigEntry, + ) -> None: + """Initialize the Hanna data coordinator.""" + self.main_coordinator = main_coordinator + self._device_data = device + self._readings = None + update_interval = timedelta(minutes=config_entry.data.get("update_interval", 1)) + super().__init__( + hass, + _LOGGER, + name=f"hanna_{self.device_identifier}", + update_interval=update_interval, + ) + + @property + def api_client(self) -> HannaCloudClient: + """Return the API client from the main coordinator.""" + return self.main_coordinator.api_client + + @property + def device_identifier(self) -> str: + """Return the device identifier.""" + return self._device_data["DID"] + + @property + def device_data(self) -> dict: + """Return the device data.""" + return self._device_data + + @property + def readings(self) -> dict: + """Return the readings.""" + return self._readings or {} + + @property + def device_info(self) -> DeviceInfo: + """Return device information for Home Assistant.""" + sy = self.device_data.get("reportedSettings", {}).get("SY") + return DeviceInfo( + identifiers={("hanna", self.device_identifier)}, + manufacturer="Hanna Instruments", + model=self.device_data.get("DM"), + name=f"{self.device_identifier} {self.device_data.get('DINFO', {}).get('deviceName')}", + serial_number=sy.split(",")[4], + sw_version="".join(sy.split(",")[2:4]).replace("/", "/"), + ) + + def get_last_update_time(self) -> str: + """Get the formatted last update time from sensor data.""" + format_string = "%Y-%m-%d %H:%M:%SZ" + last_update_ts = int(self.get_messages_value("receivedAtUTCs")) + last_update_dt = datetime.fromtimestamp(last_update_ts) + return last_update_dt.strftime(format_string) + + def get_messages(self) -> dict[str, Any]: + """Get the messages from the sensor data.""" + return self.get_readings().get("messages", {}) + + def get_messages_value(self, key: str) -> Any: + """Get the value for a specific key in the messages.""" + return self.get_messages().get(key) + + def get_glp(self) -> dict[str, Any]: + """Get the glp from the sensor data.""" + return self.get_messages_value("glp") + + def get_glp_value(self, key: str) -> Any: + """Get the value for a specific key in the glp.""" + return self.get_glp().get(key) + + def get_parameters(self) -> list[dict[str, Any]]: + """Get all parameters from the sensor data.""" + return self.get_messages_value("parameters") or [] + + def get_parameter_value(self, key: str) -> Any: + """Get the value for a specific parameter.""" + for parameter in self.get_parameters(): + if parameter["name"] == key: + return parameter["value"] + return None + + def get_readings(self) -> dict[str, Any]: + """Get the raw readings from the device.""" + return self._readings or {} + + async def _async_update_data(self): + """Fetch latest sensor data from the Hanna API.""" + try: + readings = await self.hass.async_add_executor_job( + self.api_client.get_last_device_reading, self.device_identifier + ) + self._readings = readings[0] + except RequestException as e: + raise UpdateFailed(f"Error communicating with Hanna API: {e}") from e + except (KeyError, IndexError) as e: + raise UpdateFailed(f"Error parsing Hanna API response: {e}") from e + except Exception as e: + _LOGGER.error("Unexpected error while fetching Hanna data: %s", e) + raise UpdateFailed(f"Unexpected error: {e}") from e diff --git a/homeassistant/components/hanna/manifest.json b/homeassistant/components/hanna/manifest.json new file mode 100644 index 0000000000000..41740a22d019f --- /dev/null +++ b/homeassistant/components/hanna/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "hanna", + "name": "Hanna", + "codeowners": ["@bestycame"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hanna", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["hanna-cloud==0.0.4"], + "single_config_entry": true +} diff --git a/homeassistant/components/hanna/quality_scale.yaml b/homeassistant/components/hanna/quality_scale.yaml new file mode 100644 index 0000000000000..8c346a24226c7 --- /dev/null +++ b/homeassistant/components/hanna/quality_scale.yaml @@ -0,0 +1,79 @@ +rules: + # Bronze + action-setup: + status: done + appropriate-polling: + status: done + comment: | + This integration has a default polling interval of 5 minutes. This is coherent with the HannaCloud that pulls data every 15 minutes. + brands: + status: done + comment: | + PR: https://github.com/home-assistant/brands/pull/7244 + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: + status: done + comment: | + PR: https://github.com/home-assistant/home-assistant.io/pull/39614 + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: + status: done + comment: | + This integration uses the device identifier as unique ID. The device identifier is unique for each Hanna device. + has-entity-name: + status: done + comment: | + Entities use has_entity_name = True. All Sensors have a name. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py new file mode 100644 index 0000000000000..47d94602185f6 --- /dev/null +++ b/homeassistant/components/hanna/sensor.py @@ -0,0 +1,292 @@ +"""Hanna Instruments sensor integration for Home Assistant. + +This module provides sensor entities for various Hanna Instruments devices, +including pH, ORP, temperature, and chemical sensors. It uses the Hanna API +to fetch readings and updates them periodically. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED + +from .coordinator import HannaDataCoordinator + +_LOGGER = logging.getLogger(__name__) + +SENSOR_DESCRIPTIONS = { + "ph": SensorEntityDescription( + key="ph", + name="pH value", + icon="mdi:water", + device_class=SensorDeviceClass.PH, + ), + "orp": SensorEntityDescription( + key="orp", + name="Chlorine ORP value", + icon="mdi:flash", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + ), + "temp": SensorEntityDescription( + key="temp", + name="Water Temperature", + icon="mdi:thermometer", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + "airTemp": SensorEntityDescription( + key="airTemp", + name="Air Temperature", + icon="mdi:thermometer", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + "acidBase": SensorEntityDescription( + key="acidBase", name="pH Acid/Base Flow Rate", icon="mdi:flask" + ), + "cl": SensorEntityDescription( + key="cl", name="Chlorine Flow Rate", icon="mdi:chemical-weapon" + ), + "phPumpColor": SensorEntityDescription( + key="phPumpColor", + name="pH Pump Status", + icon="mdi:pump", + ), + "clPumpColor": SensorEntityDescription( + key="clPumpColor", + name="Chlorine Pump Status", + icon="mdi:pump", + ), + "StatusColor": SensorEntityDescription( + key="StatusColor", + name="System Status", + icon="mdi:information", + ), + "ServiceColor": SensorEntityDescription( + key="ServiceColor", + name="Service Status", + icon="mdi:wrench", + ), + "alarms": SensorEntityDescription( + key="alarms", + name="Alarms", + icon="mdi:alert", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hanna sensors from a config entry.""" + device_coordinators = entry.runtime_data["device_coordinators"] + + for coordinator in device_coordinators.values(): + if not coordinator.readings: + _LOGGER.warning("No data received for %s", coordinator.device_identifier) + continue + + # Add parameter sensors + param_sensors = [] + for parameter in coordinator.get_parameters(): + if description := SENSOR_DESCRIPTIONS.get(parameter["name"]): + param_sensors.append(HannaParamSensor(coordinator, description)) + else: + _LOGGER.warning("No sensor description found for %s", parameter["name"]) + if param_sensors: + async_add_entities(param_sensors) + + # Add status sensors + status_sensors = [] + for sensor_name in coordinator.get_messages_value("status"): + if description := SENSOR_DESCRIPTIONS.get(sensor_name): + status_sensors.append(HannaStatusSensor(coordinator, description)) + else: + _LOGGER.warning("No sensor description found for %s", sensor_name) + if status_sensors: + async_add_entities(status_sensors) + + # Add alarms sensor + alarm_sensors = [] + alarm_sensors.append( + HannaAlarmSensor(coordinator, SENSOR_DESCRIPTIONS["alarms"]) + ) + if alarm_sensors: + async_add_entities(alarm_sensors) + + +class HannaSensor(SensorEntity): + """Representation of a Hanna sensor.""" + + def __init__( + self, coordinator: HannaDataCoordinator, description: SensorEntityDescription + ) -> None: + """Initialize a Hanna sensor.""" + self._attr_unique_id = f"{coordinator.device_identifier}_{description.key}" + self._attr_name = ( + None + if description.name is None or description.name is UNDEFINED + else description.name + ) + self._attr_native_value = None + self._attr_icon = description.icon + self._attr_has_entity_name = True + self._attr_should_poll = False + self.description = description + self.coordinator = coordinator + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return self.coordinator.device_info + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener(self._handle_coordinator_update) + ) + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() + + +class HannaParamSensor(HannaSensor): + """Representation of a Hanna sensor.""" + + def __init__( + self, coordinator: HannaDataCoordinator, description: SensorEntityDescription + ) -> None: + """Initialize a Hanna sensor.""" + super().__init__(coordinator, description) + + self._attr_native_value = coordinator.get_parameter_value(description.key) + self._attr_native_unit_of_measurement = description.unit_of_measurement + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_device_class = description.device_class + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return additional state attributes.""" + attrs = {"last_updated": self.coordinator.get_last_update_time()} + + # Add ORP calibration data if this is an ORP sensor + if self.description.key == "orp": + glp_data = self.coordinator.readings.get("messages", {}).get("glp", {}) + attrs.update( + { + "last_calibration": glp_data.get("orpDateTime"), + "offset": glp_data.get("orpOffset"), + "calibration_point": glp_data.get("orp"), + } + ) + # Add pH calibration data if this is a pH sensor + elif self.description.key == "ph": + glp_data = self.coordinator.readings.get("messages", {}).get("glp", {}) + attrs.update( + { + "last_calibration": glp_data.get("pHDateTime"), + "offset": glp_data.get("pHOffset"), + "slope": glp_data.get("pHSlope"), + "calibration_point_1_ph": glp_data.get("pH1"), + "calibration_point_1_mv": glp_data.get("mV1"), + "calibration_point_1_temperature": glp_data.get("temp1"), + "calibration_point_2_ph": glp_data.get("pH2"), + "calibration_point_2_mv": glp_data.get("mV2"), + "calibration_point_2_temperature": glp_data.get("temp2"), + } + ) + + return attrs + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + value = self.coordinator.get_parameter_value(self.description.key) + if value is not None: + self._attr_native_value = value + self.async_write_ha_state() + + +class HannaStatusSensor(HannaSensor): + """Representation of a Hanna status sensor.""" + + def __init__( + self, coordinator: HannaDataCoordinator, description: SensorEntityDescription + ) -> None: + """Initialize a Hanna status sensor.""" + super().__init__(coordinator, description) + self._attr_native_value = coordinator.get_messages_value("status").get( + self.description.key + ) + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return additional state attributes.""" + return {"last_updated": self.coordinator.get_last_update_time()} + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + value = self.coordinator.get_messages_value("status").get(self.description.key) + if value is not None: + self._attr_native_value = value + self.async_write_ha_state() + + +class HannaAlarmSensor(HannaSensor): + """Representation of a Hanna alarm sensor.""" + + def __init__( + self, + coordinator: HannaDataCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize a Hanna alarm sensor.""" + super().__init__(coordinator, description) + self._attr_native_value = self._get_alarm_state(coordinator.readings) + + def _get_alarm_state(self, readings: dict) -> str: + """Get the current alarm state.""" + alarms = readings.get("messages", {}).get("alarms", []) + if not alarms: + return "No Alarms" + return ", ".join(alarms) + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return additional state attributes.""" + return { + "last_updated": self.coordinator.get_last_update_time(), + "alarms": self.coordinator.get_messages_value("alarms"), + "warnings": self.coordinator.get_messages_value("warnings"), + "errors": self.coordinator.get_messages_value("errors"), + } + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + value = self._get_alarm_state(self.coordinator.readings) + if value is not None: + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/hanna/strings.json b/homeassistant/components/hanna/strings.json new file mode 100644 index 0000000000000..496f1cb3cdbae --- /dev/null +++ b/homeassistant/components/hanna/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password", + "code": "Encryption key", + "scan_interval": "Scan interval" + }, + "data_description": { + "email": "Email address for your Hanna Cloud account", + "password": "Password for your Hanna Cloud account", + "code": "Encryption key for your Hanna Cloud account", + "scan_interval": "Scan interval for your Hanna Cloud account. Default is 1 minute." + }, + "description": "Enter your Hanna Cloud credentials", + "title": "Hanna Cloud" + } + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 86f45c44fdc05..080064f72f5b4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -251,6 +251,7 @@ "growatt_server", "guardian", "habitica", + "hanna", "harmony", "heos", "here_travel_time", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index dc46ddc6e1675..be0595c6e4ac9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2552,6 +2552,13 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "hanna": { + "name": "Hanna", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "single_config_entry": true + }, "hardkernel": { "name": "Hardkernel", "integration_type": "hardware", diff --git a/requirements_all.txt b/requirements_all.txt index dbc8ac9dd73e2..4720d83990883 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1123,6 +1123,9 @@ habiticalib==0.4.0 # homeassistant.components.bluetooth habluetooth==3.48.2 +# homeassistant.components.hanna +hanna-cloud==0.0.4 + # homeassistant.components.cloud hass-nabucasa==0.101.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0820f5c19a32c..36eadb8503dd6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -978,6 +978,9 @@ habiticalib==0.4.0 # homeassistant.components.bluetooth habluetooth==3.48.2 +# homeassistant.components.hanna +hanna-cloud==0.0.4 + # homeassistant.components.cloud hass-nabucasa==0.101.0 diff --git a/tests/components/hanna/__init__.py b/tests/components/hanna/__init__.py new file mode 100644 index 0000000000000..47a9b1d5aab7d --- /dev/null +++ b/tests/components/hanna/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hanna integration.""" diff --git a/tests/components/hanna/conftest.py b/tests/components/hanna/conftest.py new file mode 100644 index 0000000000000..0f1fb9e0898c0 --- /dev/null +++ b/tests/components/hanna/conftest.py @@ -0,0 +1,43 @@ +"""Fixtures for Hanna Instruments integration tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.hanna.const import DOMAIN +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD, CONF_SCAN_INTERVAL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch("homeassistant.components.hanna.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_hanna_client(): + """Mock HannaCloudClient.""" + with patch( + "homeassistant.components.hanna.config_flow.HannaCloudClient" + ) as mock_client: + client = mock_client.return_value + client.authenticate = AsyncMock() + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + CONF_CODE: "test-code", + CONF_SCAN_INTERVAL: 1, + }, + title="test@example.com", + ) diff --git a/tests/components/hanna/test_config_flow.py b/tests/components/hanna/test_config_flow.py new file mode 100644 index 0000000000000..c5b6447b7a05f --- /dev/null +++ b/tests/components/hanna/test_config_flow.py @@ -0,0 +1,86 @@ +"""Tests for the Hanna Instruments integration config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from hanna_cloud.client import AuthenticationError +import pytest + +from homeassistant.components.hanna.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +def mock_hanna_client(): + """Mock Hanna Cloud client.""" + with patch( + "homeassistant.components.hanna.config_flow.HannaCloudClient" + ) as mock_client: + client = mock_client.return_value + client.authenticate = ( + MagicMock() + ) # Use MagicMock instead of AsyncMock since it's called synchronously + yield client + + +async def test_full_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hanna_client: MagicMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test@example.com", + "password": "test-password", + "code": "test-code", + "scan_interval": 1, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == { + "email": "test@example.com", + "password": "test-password", + "code": "test-code", + "scan_interval": 1, + } + + +async def test_invalid_auth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hanna_client: MagicMock, +) -> None: + """Test invalid authentication.""" + mock_hanna_client.authenticate.side_effect = AuthenticationError( + "Invalid authentication" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "email": "test@example.com", + "password": "test-password", + "code": "test-code", + "scan_interval": 1, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} From 06ef095e6d65c7193de2a6dfdbdb7edfb907b70c Mon Sep 17 00:00:00 2001 From: bestycame Date: Wed, 18 Jun 2025 15:19:31 +0200 Subject: [PATCH 02/26] Update homeassistant/components/hanna/strings.json Co-authored-by: Norbert Rittel --- homeassistant/components/hanna/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hanna/strings.json b/homeassistant/components/hanna/strings.json index 496f1cb3cdbae..4e20e7fb9c8b2 100644 --- a/homeassistant/components/hanna/strings.json +++ b/homeassistant/components/hanna/strings.json @@ -24,7 +24,7 @@ "unknown": "Unexpected error" }, "abort": { - "already_configured": "Device is already configured" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } } From ff89533364ad389245e95dc11e52c86fdd83acb2 Mon Sep 17 00:00:00 2001 From: bestycame Date: Wed, 18 Jun 2025 15:19:39 +0200 Subject: [PATCH 03/26] Update homeassistant/components/hanna/strings.json Co-authored-by: Norbert Rittel --- homeassistant/components/hanna/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hanna/strings.json b/homeassistant/components/hanna/strings.json index 4e20e7fb9c8b2..ffa8d36a0067d 100644 --- a/homeassistant/components/hanna/strings.json +++ b/homeassistant/components/hanna/strings.json @@ -19,9 +19,9 @@ } }, "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" From 45df6dcb97ab4bead169ad9b6b44526e90ffd7ec Mon Sep 17 00:00:00 2001 From: bestycame Date: Wed, 18 Jun 2025 15:19:57 +0200 Subject: [PATCH 04/26] Update homeassistant/components/hanna/strings.json Co-authored-by: Norbert Rittel --- homeassistant/components/hanna/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hanna/strings.json b/homeassistant/components/hanna/strings.json index ffa8d36a0067d..6d843bd3a124e 100644 --- a/homeassistant/components/hanna/strings.json +++ b/homeassistant/components/hanna/strings.json @@ -3,8 +3,8 @@ "step": { "user": { "data": { - "email": "Email", - "password": "Password", + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]", "code": "Encryption key", "scan_interval": "Scan interval" }, From 95ecc538e66b7240b8d642a90aa15c86945b51ea Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Fri, 18 Jul 2025 21:18:36 +0000 Subject: [PATCH 05/26] refactor following PR review comment --- homeassistant/components/hanna/__init__.py | 47 +++++------- homeassistant/components/hanna/config_flow.py | 49 ++++++++++++- homeassistant/components/hanna/const.py | 19 +++++ homeassistant/components/hanna/coordinator.py | 73 ++++--------------- homeassistant/components/hanna/manifest.json | 2 +- homeassistant/components/hanna/sensor.py | 24 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hanna/conftest.py | 9 ++- tests/components/hanna/test_config_flow.py | 31 +++----- 10 files changed, 126 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/hanna/__init__.py b/homeassistant/components/hanna/__init__.py index cb81a87d9a5c1..588f471c2cab2 100644 --- a/homeassistant/components/hanna/__init__.py +++ b/homeassistant/components/hanna/__init__.py @@ -2,49 +2,41 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry +from hanna_cloud import HannaCloudClient + from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_validation import config_entry_only_config_schema -from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_integration -from .const import DOMAIN -from .coordinator import HannaDataCoordinator, HannaMainCoordinator +from .const import HannaConfigEntry +from .coordinator import HannaDataCoordinator PLATFORMS = [Platform.SENSOR] -CONFIG_SCHEMA = config_entry_only_config_schema(DOMAIN) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Hanna Instruments component.""" - _ = await async_get_integration(hass, DOMAIN) - hass.data.setdefault(DOMAIN, {}) - return True - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool: """Set up Hanna Instruments from a config entry.""" - - # Create main coordinator - main_coordinator = HannaMainCoordinator(hass, entry) - await main_coordinator.async_authenticate( - entry.data["email"], entry.data["password"], entry.data["code"] + # Create a temporary API client to discover devices + + api_client = HannaCloudClient() + await hass.async_add_executor_job( + api_client.authenticate, + entry.data["email"], + entry.data["password"], + entry.data["code"], ) - await main_coordinator.async_config_entry_first_refresh() + + # Get devices + devices = await hass.async_add_executor_job(api_client.get_devices) # Create device coordinators - devices = await main_coordinator.async_get_devices() device_coordinators = {} for device in devices: - coordinator = HannaDataCoordinator(hass, main_coordinator, device, entry) + coordinator = HannaDataCoordinator(hass, entry, device) await coordinator.async_config_entry_first_refresh() device_coordinators[coordinator.device_identifier] = coordinator # Set runtime data entry.runtime_data = { - "main_coordinator": main_coordinator, "device_coordinators": device_coordinators, } @@ -53,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool: """Unload a config entry.""" # Unload platforms unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -64,9 +56,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in entry.runtime_data["device_coordinators"].values(): await coordinator.async_shutdown() - # Clean up main coordinator - await entry.runtime_data["main_coordinator"].async_shutdown() - # Clear runtime data entry.runtime_data = None diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py index def5f9a400314..e2970df9cf8aa 100644 --- a/homeassistant/components/hanna/config_flow.py +++ b/homeassistant/components/hanna/config_flow.py @@ -6,10 +6,15 @@ from typing import Any from hanna_cloud import HannaCloudClient +from requests.exceptions import ( + ConnectionError as RequestsConnectionError, + RequestException, + Timeout, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD from .const import DEFAULT_ENCRYPTION_KEY, DOMAIN @@ -25,7 +30,6 @@ class HannaConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str, vol.Required(CONF_CODE, default=DEFAULT_ENCRYPTION_KEY): str, - vol.Required(CONF_SCAN_INTERVAL, default=5): int, } ) @@ -50,14 +54,33 @@ async def async_step_user( user_input[CONF_PASSWORD], user_input[CONF_CODE], ) + except (Timeout, RequestsConnectionError): + _LOGGER.exception("Connection timeout or error") + errors["base"] = "cannot_connect" + except RequestException as ex: + if hasattr(ex, "response") and ex.response is not None: + if ex.response.status_code in (401, 403): + _LOGGER.exception("Authentication failed") + errors["base"] = "invalid_auth" + else: + _LOGGER.exception( + "Request failed with status %s", ex.response.status_code + ) + errors["base"] = "cannot_connect" + else: + _LOGGER.exception("Request failed") + errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") - errors["base"] = "invalid_auth" + errors["base"] = "unknown" + + if errors: return self.async_show_form( step_id="user", data_schema=self.data_schema, errors=errors, ) + return self.async_create_entry( title=user_input[CONF_EMAIL], data=user_input, @@ -84,9 +107,27 @@ async def async_step_reconfigure( user_input[CONF_PASSWORD], user_input[CONF_CODE], ) + except (Timeout, RequestsConnectionError): + _LOGGER.exception("Connection timeout or error") + errors["base"] = "cannot_connect" + except RequestException as ex: + if hasattr(ex, "response") and ex.response is not None: + if ex.response.status_code in (401, 403): + _LOGGER.exception("Authentication failed") + errors["base"] = "invalid_auth" + else: + _LOGGER.exception( + "Request failed with status %s", ex.response.status_code + ) + errors["base"] = "cannot_connect" + else: + _LOGGER.exception("Request failed") + errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") - errors["base"] = "invalid_auth" + errors["base"] = "unknown" + + if errors: return self.async_show_form( step_id="reconfigure", data_schema=self.data_schema, diff --git a/homeassistant/components/hanna/const.py b/homeassistant/components/hanna/const.py index 6de11935b0fc9..8a82a20a35099 100644 --- a/homeassistant/components/hanna/const.py +++ b/homeassistant/components/hanna/const.py @@ -1,6 +1,25 @@ """Constants for the Hanna integration.""" +from typing import TYPE_CHECKING, TypedDict + +from homeassistant.config_entries import ConfigEntry + +if TYPE_CHECKING: + from .coordinator import HannaDataCoordinator + DOMAIN = "hanna" # This key is NOT private. It is found in the JavaScript code of the Hanna Cloud webapp at https://www.hannacloud.com DEFAULT_ENCRYPTION_KEY = "MzJmODBmMDU0ZTAyNDFjYWM0YTVhOGQxY2ZlZTkwMDM=" + + +class HannaRuntimeData(TypedDict): + """Runtime data for Hanna config entries.""" + + device_coordinators: dict[str, "HannaDataCoordinator"] + + +class HannaConfigEntry(ConfigEntry): + """Config entry for Hanna integration with typed runtime data.""" + + runtime_data: HannaRuntimeData | None diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py index a246cdc113c29..bc370fc00f758 100644 --- a/homeassistant/components/hanna/coordinator.py +++ b/homeassistant/components/hanna/coordinator.py @@ -4,7 +4,7 @@ sensor data. """ -from datetime import datetime, timedelta +from datetime import UTC, datetime import logging from typing import Any @@ -19,78 +19,37 @@ _LOGGER = logging.getLogger(__name__) -class HannaMainCoordinator(DataUpdateCoordinator): - """Main coordinator for Hanna Instruments authentication and device management.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - ) -> None: - """Initialize the main Hanna coordinator.""" - self.api_client = HannaCloudClient() - self._devices: dict[str, dict] = {} - update_interval = timedelta(minutes=config_entry.data.get("update_interval", 1)) - super().__init__( - hass, - _LOGGER, - name="hanna_main", - update_interval=update_interval, - ) - - async def async_authenticate(self, email: str, password: str, code: str) -> None: - """Authenticate with the Hanna API.""" - await self.hass.async_add_executor_job( - self.api_client.authenticate, email, password, code - ) - - async def async_get_devices(self) -> list[dict]: - """Get all devices associated with the account.""" - devices = await self.hass.async_add_executor_job(self.api_client.get_devices) - self._devices = {device.get("DID"): device for device in devices} - return devices - - def get_device(self, device_id: str) -> dict: - """Get a specific device by ID.""" - return self._devices[device_id] - - async def _async_update_data(self): - """Update the list of devices.""" - try: - await self.async_get_devices() - except Exception as e: - _LOGGER.error("Error updating devices: %s", e) - raise UpdateFailed(f"Error updating devices: {e}") from e - else: - return self._devices - - class HannaDataCoordinator(DataUpdateCoordinator): """Coordinator for fetching Hanna sensor data.""" def __init__( self, hass: HomeAssistant, - main_coordinator: HannaMainCoordinator, - device: dict, config_entry: ConfigEntry, + device: dict, ) -> None: """Initialize the Hanna data coordinator.""" - self.main_coordinator = main_coordinator + self.api_client = HannaCloudClient() self._device_data = device self._readings = None - update_interval = timedelta(minutes=config_entry.data.get("update_interval", 1)) + self._email = config_entry.data["email"] + self._password = config_entry.data["password"] + self._code = config_entry.data["code"] super().__init__( hass, _LOGGER, name=f"hanna_{self.device_identifier}", - update_interval=update_interval, ) - @property - def api_client(self) -> HannaCloudClient: - """Return the API client from the main coordinator.""" - return self.main_coordinator.api_client + async def _async_setup(self) -> None: + """Set up the coordinator by authenticating with the Hanna API.""" + await self.async_authenticate(self._email, self._password, self._code) + + async def async_authenticate(self, email: str, password: str, code: str) -> None: + """Authenticate with the Hanna API.""" + await self.hass.async_add_executor_job( + self.api_client.authenticate, email, password, code + ) @property def device_identifier(self) -> str: @@ -124,7 +83,7 @@ def get_last_update_time(self) -> str: """Get the formatted last update time from sensor data.""" format_string = "%Y-%m-%d %H:%M:%SZ" last_update_ts = int(self.get_messages_value("receivedAtUTCs")) - last_update_dt = datetime.fromtimestamp(last_update_ts) + last_update_dt = datetime.fromtimestamp(last_update_ts, tz=UTC) return last_update_dt.strftime(format_string) def get_messages(self) -> dict[str, Any]: diff --git a/homeassistant/components/hanna/manifest.json b/homeassistant/components/hanna/manifest.json index 41740a22d019f..ed710ccf3b6da 100644 --- a/homeassistant/components/hanna/manifest.json +++ b/homeassistant/components/hanna/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hanna", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["hanna-cloud==0.0.4"], + "requirements": ["hanna-cloud==0.0.3"], "single_config_entry": true } diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py index 47d94602185f6..9ac20aa83693a 100644 --- a/homeassistant/components/hanna/sensor.py +++ b/homeassistant/components/hanna/sensor.py @@ -98,38 +98,36 @@ async def async_setup_entry( """Set up Hanna sensors from a config entry.""" device_coordinators = entry.runtime_data["device_coordinators"] + # Collect all entities during initialization + all_entities: list[HannaParamSensor | HannaStatusSensor | HannaAlarmSensor] = [] + for coordinator in device_coordinators.values(): if not coordinator.readings: _LOGGER.warning("No data received for %s", coordinator.device_identifier) continue # Add parameter sensors - param_sensors = [] for parameter in coordinator.get_parameters(): if description := SENSOR_DESCRIPTIONS.get(parameter["name"]): - param_sensors.append(HannaParamSensor(coordinator, description)) + all_entities.append(HannaParamSensor(coordinator, description)) else: _LOGGER.warning("No sensor description found for %s", parameter["name"]) - if param_sensors: - async_add_entities(param_sensors) # Add status sensors - status_sensors = [] for sensor_name in coordinator.get_messages_value("status"): if description := SENSOR_DESCRIPTIONS.get(sensor_name): - status_sensors.append(HannaStatusSensor(coordinator, description)) + all_entities.append(HannaStatusSensor(coordinator, description)) else: _LOGGER.warning("No sensor description found for %s", sensor_name) - if status_sensors: - async_add_entities(status_sensors) # Add alarms sensor - alarm_sensors = [] - alarm_sensors.append( + all_entities.append( HannaAlarmSensor(coordinator, SENSOR_DESCRIPTIONS["alarms"]) ) - if alarm_sensors: - async_add_entities(alarm_sensors) + + # Add all entities at once + if all_entities: + async_add_entities(all_entities) class HannaSensor(SensorEntity): @@ -184,7 +182,7 @@ def __init__( super().__init__(coordinator, description) self._attr_native_value = coordinator.get_parameter_value(description.key) - self._attr_native_unit_of_measurement = description.unit_of_measurement + self._attr_native_unit_of_measurement = description.native_unit_of_measurement self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_device_class = description.device_class diff --git a/requirements_all.txt b/requirements_all.txt index 40a15242b070a..27328f6f435e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.hanna -hanna-cloud==0.0.4 +hanna-cloud==0.0.3 # homeassistant.components.cloud hass-nabucasa==0.102.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4480e53e88612..5e94833ce902e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -982,7 +982,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.hanna -hanna-cloud==0.0.4 +hanna-cloud==0.0.3 # homeassistant.components.cloud hass-nabucasa==0.102.0 diff --git a/tests/components/hanna/conftest.py b/tests/components/hanna/conftest.py index 0f1fb9e0898c0..37927e2f2a058 100644 --- a/tests/components/hanna/conftest.py +++ b/tests/components/hanna/conftest.py @@ -1,11 +1,11 @@ """Fixtures for Hanna Instruments integration tests.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import MagicMock, patch import pytest from homeassistant.components.hanna.const import DOMAIN -from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD from tests.common import MockConfigEntry @@ -24,7 +24,9 @@ def mock_hanna_client(): "homeassistant.components.hanna.config_flow.HannaCloudClient" ) as mock_client: client = mock_client.return_value - client.authenticate = AsyncMock() + client.authenticate = ( + MagicMock() + ) # Use MagicMock since it's called synchronously yield client @@ -37,7 +39,6 @@ def mock_config_entry() -> MockConfigEntry: CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password", CONF_CODE: "test-code", - CONF_SCAN_INTERVAL: 1, }, title="test@example.com", ) diff --git a/tests/components/hanna/test_config_flow.py b/tests/components/hanna/test_config_flow.py index c5b6447b7a05f..6de74cf0206ea 100644 --- a/tests/components/hanna/test_config_flow.py +++ b/tests/components/hanna/test_config_flow.py @@ -1,9 +1,8 @@ """Tests for the Hanna Instruments integration config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock -from hanna_cloud.client import AuthenticationError -import pytest +from requests.exceptions import RequestException from homeassistant.components.hanna.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -11,19 +10,6 @@ from homeassistant.data_entry_flow import FlowResultType -@pytest.fixture -def mock_hanna_client(): - """Mock Hanna Cloud client.""" - with patch( - "homeassistant.components.hanna.config_flow.HannaCloudClient" - ) as mock_client: - client = mock_client.return_value - client.authenticate = ( - MagicMock() - ) # Use MagicMock instead of AsyncMock since it's called synchronously - yield client - - async def test_full_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -43,7 +29,6 @@ async def test_full_flow( "email": "test@example.com", "password": "test-password", "code": "test-code", - "scan_interval": 1, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -52,7 +37,6 @@ async def test_full_flow( "email": "test@example.com", "password": "test-password", "code": "test-code", - "scan_interval": 1, } @@ -62,9 +46,13 @@ async def test_invalid_auth( mock_hanna_client: MagicMock, ) -> None: """Test invalid authentication.""" - mock_hanna_client.authenticate.side_effect = AuthenticationError( - "Invalid authentication" - ) + # Create a RequestException with 401 status code to simulate authentication failure + auth_error = RequestException("Authentication failed") + auth_error.response = MagicMock() + auth_error.response.status_code = 401 + + mock_hanna_client.authenticate.side_effect = auth_error + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -78,7 +66,6 @@ async def test_invalid_auth( "email": "test@example.com", "password": "test-password", "code": "test-code", - "scan_interval": 1, }, ) assert result["type"] is FlowResultType.FORM From ef618668ecb36cd8363a43e58f8dfcdff538ae9d Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Tue, 22 Jul 2025 08:14:46 +0000 Subject: [PATCH 06/26] Correct case of sensor names --- homeassistant/components/hanna/sensor.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py index 9ac20aa83693a..ec2a8390013c6 100644 --- a/homeassistant/components/hanna/sensor.py +++ b/homeassistant/components/hanna/sensor.py @@ -44,42 +44,42 @@ ), "temp": SensorEntityDescription( key="temp", - name="Water Temperature", + name="Water temperature", icon="mdi:thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), "airTemp": SensorEntityDescription( key="airTemp", - name="Air Temperature", + name="Air temperature", icon="mdi:thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), "acidBase": SensorEntityDescription( - key="acidBase", name="pH Acid/Base Flow Rate", icon="mdi:flask" + key="acidBase", name="pH Acid/Base flow rate", icon="mdi:flask" ), "cl": SensorEntityDescription( - key="cl", name="Chlorine Flow Rate", icon="mdi:chemical-weapon" + key="cl", name="Chlorine flow rate", icon="mdi:chemical-weapon" ), "phPumpColor": SensorEntityDescription( key="phPumpColor", - name="pH Pump Status", + name="pH pump status", icon="mdi:pump", ), "clPumpColor": SensorEntityDescription( key="clPumpColor", - name="Chlorine Pump Status", + name="Chlorine pump status", icon="mdi:pump", ), "StatusColor": SensorEntityDescription( key="StatusColor", - name="System Status", + name="System status", icon="mdi:information", ), "ServiceColor": SensorEntityDescription( key="ServiceColor", - name="Service Status", + name="Service status", icon="mdi:wrench", ), "alarms": SensorEntityDescription( From 665839db229d3fcbc836004c9e185aaf11d0de6b Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Fri, 25 Jul 2025 10:37:03 +0000 Subject: [PATCH 07/26] Refactor Hanna integration to streamline authentication and device management --- homeassistant/components/hanna/__init__.py | 34 ++-- homeassistant/components/hanna/config_flow.py | 105 ++--------- homeassistant/components/hanna/const.py | 13 +- homeassistant/components/hanna/coordinator.py | 84 +++------ homeassistant/components/hanna/manifest.json | 2 +- homeassistant/components/hanna/sensor.py | 174 ++---------------- homeassistant/components/hanna/strings.json | 8 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 82 insertions(+), 342 deletions(-) diff --git a/homeassistant/components/hanna/__init__.py b/homeassistant/components/hanna/__init__.py index 588f471c2cab2..dcc772297d252 100644 --- a/homeassistant/components/hanna/__init__.py +++ b/homeassistant/components/hanna/__init__.py @@ -13,21 +13,27 @@ PLATFORMS = [Platform.SENSOR] +def _authenticate_and_get_devices( + api_client: HannaCloudClient, + email: str, + password: str, +) -> list: + """Authenticate and get devices in a single executor job.""" + api_client.authenticate(email, password) + return api_client.get_devices() + + async def async_setup_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool: """Set up Hanna Instruments from a config entry.""" # Create a temporary API client to discover devices - api_client = HannaCloudClient() - await hass.async_add_executor_job( - api_client.authenticate, + devices = await hass.async_add_executor_job( + _authenticate_and_get_devices, + api_client, entry.data["email"], entry.data["password"], - entry.data["code"], ) - # Get devices - devices = await hass.async_add_executor_job(api_client.get_devices) - # Create device coordinators device_coordinators = {} for device in devices: @@ -36,9 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> boo device_coordinators[coordinator.device_identifier] = coordinator # Set runtime data - entry.runtime_data = { - "device_coordinators": device_coordinators, - } + entry.runtime_data = device_coordinators # Forward the setup to the platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -50,13 +54,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bo # Unload platforms unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - # Clean up coordinators - if unload_ok and entry.runtime_data: - # Clean up device coordinators - for coordinator in entry.runtime_data["device_coordinators"].values(): + # Clean up and device coordinators + if unload_ok and entry.runtime_data is not None: + for coordinator in entry.runtime_data.values(): await coordinator.async_shutdown() - # Clear runtime data - entry.runtime_data = None - return unload_ok diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py index e2970df9cf8aa..200a80d979cd7 100644 --- a/homeassistant/components/hanna/config_flow.py +++ b/homeassistant/components/hanna/config_flow.py @@ -5,18 +5,14 @@ import logging from typing import Any -from hanna_cloud import HannaCloudClient -from requests.exceptions import ( - ConnectionError as RequestsConnectionError, - RequestException, - Timeout, -) +from hanna_cloud import AuthenticationError, HannaCloudClient +from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from .const import DEFAULT_ENCRYPTION_KEY, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -26,52 +22,34 @@ class HannaConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 data_schema = vol.Schema( - { - vol.Required(CONF_EMAIL): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_CODE, default=DEFAULT_ENCRYPTION_KEY): str, - } + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} ) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} - + """Handle the setup flow.""" if user_input is None: return self.async_show_form( step_id="user", data_schema=self.data_schema, - errors=errors, ) + errors: dict[str, str] = {} + try: client = HannaCloudClient() await self.hass.async_add_executor_job( - client.authenticate, - user_input[CONF_EMAIL], - user_input[CONF_PASSWORD], - user_input[CONF_CODE], + client.authenticate, user_input[CONF_EMAIL], user_input[CONF_PASSWORD] ) except (Timeout, RequestsConnectionError): - _LOGGER.exception("Connection timeout or error") + _LOGGER.warning("Connection timeout or error during Hanna authentication") errors["base"] = "cannot_connect" - except RequestException as ex: - if hasattr(ex, "response") and ex.response is not None: - if ex.response.status_code in (401, 403): - _LOGGER.exception("Authentication failed") - errors["base"] = "invalid_auth" - else: - _LOGGER.exception( - "Request failed with status %s", ex.response.status_code - ) - errors["base"] = "cannot_connect" - else: - _LOGGER.exception("Request failed") - errors["base"] = "cannot_connect" + except AuthenticationError: + _LOGGER.warning("Authentication failed for user %s", user_input[CONF_EMAIL]) + errors["base"] = "invalid_auth" except Exception: - _LOGGER.exception("Unexpected exception") + _LOGGER.exception("Unexpected error during Hanna authentication") errors["base"] = "unknown" if errors: @@ -85,58 +63,3 @@ async def async_step_user( title=user_input[CONF_EMAIL], data=user_input, ) - - async def async_step_reconfigure( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle reconfiguration.""" - errors: dict[str, str] = {} - - if user_input is None: - return self.async_show_form( - step_id="reconfigure", - data_schema=self.data_schema, - errors=errors, - ) - - try: - client = HannaCloudClient() - await self.hass.async_add_executor_job( - client.authenticate, - user_input[CONF_EMAIL], - user_input[CONF_PASSWORD], - user_input[CONF_CODE], - ) - except (Timeout, RequestsConnectionError): - _LOGGER.exception("Connection timeout or error") - errors["base"] = "cannot_connect" - except RequestException as ex: - if hasattr(ex, "response") and ex.response is not None: - if ex.response.status_code in (401, 403): - _LOGGER.exception("Authentication failed") - errors["base"] = "invalid_auth" - else: - _LOGGER.exception( - "Request failed with status %s", ex.response.status_code - ) - errors["base"] = "cannot_connect" - else: - _LOGGER.exception("Request failed") - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - if errors: - return self.async_show_form( - step_id="reconfigure", - data_schema=self.data_schema, - errors=errors, - ) - - # Update the existing entry - reconfigure_entry = self._get_reconfigure_entry() - return self.async_update_reload_and_abort( - reconfigure_entry, - data=user_input, - ) diff --git a/homeassistant/components/hanna/const.py b/homeassistant/components/hanna/const.py index 8a82a20a35099..bbf4d41830142 100644 --- a/homeassistant/components/hanna/const.py +++ b/homeassistant/components/hanna/const.py @@ -1,6 +1,6 @@ """Constants for the Hanna integration.""" -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING from homeassistant.config_entries import ConfigEntry @@ -9,17 +9,8 @@ DOMAIN = "hanna" -# This key is NOT private. It is found in the JavaScript code of the Hanna Cloud webapp at https://www.hannacloud.com -DEFAULT_ENCRYPTION_KEY = "MzJmODBmMDU0ZTAyNDFjYWM0YTVhOGQxY2ZlZTkwMDM=" - - -class HannaRuntimeData(TypedDict): - """Runtime data for Hanna config entries.""" - - device_coordinators: dict[str, "HannaDataCoordinator"] - class HannaConfigEntry(ConfigEntry): """Config entry for Hanna integration with typed runtime data.""" - runtime_data: HannaRuntimeData | None + runtime_data: dict[str, "HannaDataCoordinator"] | None diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py index bc370fc00f758..60d359e9e0947 100644 --- a/homeassistant/components/hanna/coordinator.py +++ b/homeassistant/components/hanna/coordinator.py @@ -4,7 +4,7 @@ sensor data. """ -from datetime import UTC, datetime +from datetime import timedelta import logging from typing import Any @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) -class HannaDataCoordinator(DataUpdateCoordinator): +class HannaDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator for fetching Hanna sensor data.""" def __init__( @@ -30,81 +30,52 @@ def __init__( ) -> None: """Initialize the Hanna data coordinator.""" self.api_client = HannaCloudClient() - self._device_data = device - self._readings = None + self.readings = None + self.device_data = device self._email = config_entry.data["email"] self._password = config_entry.data["password"] - self._code = config_entry.data["code"] super().__init__( hass, _LOGGER, name=f"hanna_{self.device_identifier}", + update_interval=timedelta(seconds=30), ) + self._authenticated = False - async def _async_setup(self) -> None: - """Set up the coordinator by authenticating with the Hanna API.""" - await self.async_authenticate(self._email, self._password, self._code) - - async def async_authenticate(self, email: str, password: str, code: str) -> None: - """Authenticate with the Hanna API.""" - await self.hass.async_add_executor_job( - self.api_client.authenticate, email, password, code - ) + async def ensure_authenticated(self) -> None: + """Ensure the client is authenticated with the Hanna API.""" + if not self._authenticated: + await self.hass.async_add_executor_job( + self.api_client.authenticate, self._email, self._password + ) + self._authenticated = True @property def device_identifier(self) -> str: """Return the device identifier.""" - return self._device_data["DID"] - - @property - def device_data(self) -> dict: - """Return the device data.""" - return self._device_data - - @property - def readings(self) -> dict: - """Return the readings.""" - return self._readings or {} + return self.device_data["DID"] @property def device_info(self) -> DeviceInfo: """Return device information for Home Assistant.""" - sy = self.device_data.get("reportedSettings", {}).get("SY") return DeviceInfo( identifiers={("hanna", self.device_identifier)}, - manufacturer="Hanna Instruments", + manufacturer=self.device_data.get("manufacturer"), model=self.device_data.get("DM"), - name=f"{self.device_identifier} {self.device_data.get('DINFO', {}).get('deviceName')}", - serial_number=sy.split(",")[4], - sw_version="".join(sy.split(",")[2:4]).replace("/", "/"), + name=f"{self.device_identifier} {self.device_data.get('name')}", + serial_number=self.device_data.get("serial_number"), + sw_version=self.device_data.get("sw_version"), ) - def get_last_update_time(self) -> str: - """Get the formatted last update time from sensor data.""" - format_string = "%Y-%m-%d %H:%M:%SZ" - last_update_ts = int(self.get_messages_value("receivedAtUTCs")) - last_update_dt = datetime.fromtimestamp(last_update_ts, tz=UTC) - return last_update_dt.strftime(format_string) - - def get_messages(self) -> dict[str, Any]: - """Get the messages from the sensor data.""" - return self.get_readings().get("messages", {}) - - def get_messages_value(self, key: str) -> Any: - """Get the value for a specific key in the messages.""" - return self.get_messages().get(key) - - def get_glp(self) -> dict[str, Any]: - """Get the glp from the sensor data.""" - return self.get_messages_value("glp") - - def get_glp_value(self, key: str) -> Any: - """Get the value for a specific key in the glp.""" - return self.get_glp().get(key) + def get_all_alarms(self) -> list[str]: + """Get all alarms from the sensor data.""" + return ( + self.api_client.alarms + self.api_client.errors + self.api_client.warnings + ) def get_parameters(self) -> list[dict[str, Any]]: """Get all parameters from the sensor data.""" - return self.get_messages_value("parameters") or [] + return self.api_client.parameters def get_parameter_value(self, key: str) -> Any: """Get the value for a specific parameter.""" @@ -113,17 +84,14 @@ def get_parameter_value(self, key: str) -> Any: return parameter["value"] return None - def get_readings(self) -> dict[str, Any]: - """Get the raw readings from the device.""" - return self._readings or {} - async def _async_update_data(self): """Fetch latest sensor data from the Hanna API.""" try: + await self.ensure_authenticated() readings = await self.hass.async_add_executor_job( self.api_client.get_last_device_reading, self.device_identifier ) - self._readings = readings[0] + self.readings = readings except RequestException as e: raise UpdateFailed(f"Error communicating with Hanna API: {e}") from e except (KeyError, IndexError) as e: diff --git a/homeassistant/components/hanna/manifest.json b/homeassistant/components/hanna/manifest.json index ed710ccf3b6da..9c337ec69df38 100644 --- a/homeassistant/components/hanna/manifest.json +++ b/homeassistant/components/hanna/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hanna", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["hanna-cloud==0.0.3"], + "requirements": ["hanna-cloud==0.0.5"], "single_config_entry": true } diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py index ec2a8390013c6..538a8b29204b8 100644 --- a/homeassistant/components/hanna/sensor.py +++ b/homeassistant/components/hanna/sensor.py @@ -7,9 +7,7 @@ from __future__ import annotations -from collections.abc import Mapping import logging -from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -22,7 +20,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.typing import UNDEFINED +from homeassistant.helpers.typing import UNDEFINED, StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import HannaDataCoordinator @@ -32,13 +31,13 @@ "ph": SensorEntityDescription( key="ph", name="pH value", - icon="mdi:water", + icon="mdi:flask", device_class=SensorDeviceClass.PH, ), "orp": SensorEntityDescription( key="orp", name="Chlorine ORP value", - icon="mdi:flash", + icon="mdi:flask", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, ), @@ -57,31 +56,11 @@ native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), "acidBase": SensorEntityDescription( - key="acidBase", name="pH Acid/Base flow rate", icon="mdi:flask" + key="acidBase", name="pH Acid/Base flow rate", icon="mdi:chemical-weapon" ), "cl": SensorEntityDescription( key="cl", name="Chlorine flow rate", icon="mdi:chemical-weapon" ), - "phPumpColor": SensorEntityDescription( - key="phPumpColor", - name="pH pump status", - icon="mdi:pump", - ), - "clPumpColor": SensorEntityDescription( - key="clPumpColor", - name="Chlorine pump status", - icon="mdi:pump", - ), - "StatusColor": SensorEntityDescription( - key="StatusColor", - name="System status", - icon="mdi:information", - ), - "ServiceColor": SensorEntityDescription( - key="ServiceColor", - name="Service status", - icon="mdi:wrench", - ), "alarms": SensorEntityDescription( key="alarms", name="Alarms", @@ -96,16 +75,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hanna sensors from a config entry.""" - device_coordinators = entry.runtime_data["device_coordinators"] + device_coordinators = entry.runtime_data # Collect all entities during initialization - all_entities: list[HannaParamSensor | HannaStatusSensor | HannaAlarmSensor] = [] + all_entities: list[HannaParamSensor | HannaAlarmSensor] = [] for coordinator in device_coordinators.values(): - if not coordinator.readings: - _LOGGER.warning("No data received for %s", coordinator.device_identifier) - continue - # Add parameter sensors for parameter in coordinator.get_parameters(): if description := SENSOR_DESCRIPTIONS.get(parameter["name"]): @@ -113,64 +88,39 @@ async def async_setup_entry( else: _LOGGER.warning("No sensor description found for %s", parameter["name"]) - # Add status sensors - for sensor_name in coordinator.get_messages_value("status"): - if description := SENSOR_DESCRIPTIONS.get(sensor_name): - all_entities.append(HannaStatusSensor(coordinator, description)) - else: - _LOGGER.warning("No sensor description found for %s", sensor_name) - # Add alarms sensor all_entities.append( HannaAlarmSensor(coordinator, SENSOR_DESCRIPTIONS["alarms"]) ) - # Add all entities at once if all_entities: async_add_entities(all_entities) -class HannaSensor(SensorEntity): +class HannaSensor(CoordinatorEntity[HannaDataCoordinator], SensorEntity): """Representation of a Hanna sensor.""" def __init__( self, coordinator: HannaDataCoordinator, description: SensorEntityDescription ) -> None: """Initialize a Hanna sensor.""" + super().__init__(coordinator) self._attr_unique_id = f"{coordinator.device_identifier}_{description.key}" self._attr_name = ( None if description.name is None or description.name is UNDEFINED else description.name ) - self._attr_native_value = None self._attr_icon = description.icon self._attr_has_entity_name = True self._attr_should_poll = False self.description = description - self.coordinator = coordinator - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.coordinator.last_update_success @property def device_info(self) -> DeviceInfo: """Return device information.""" return self.coordinator.device_info - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - self.async_on_remove( - self.coordinator.async_add_listener(self._handle_coordinator_update) - ) - - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.async_write_ha_state() - class HannaParamSensor(HannaSensor): """Representation of a Hanna sensor.""" @@ -180,111 +130,23 @@ def __init__( ) -> None: """Initialize a Hanna sensor.""" super().__init__(coordinator, description) - - self._attr_native_value = coordinator.get_parameter_value(description.key) self._attr_native_unit_of_measurement = description.native_unit_of_measurement self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_device_class = description.device_class @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return additional state attributes.""" - attrs = {"last_updated": self.coordinator.get_last_update_time()} - - # Add ORP calibration data if this is an ORP sensor - if self.description.key == "orp": - glp_data = self.coordinator.readings.get("messages", {}).get("glp", {}) - attrs.update( - { - "last_calibration": glp_data.get("orpDateTime"), - "offset": glp_data.get("orpOffset"), - "calibration_point": glp_data.get("orp"), - } - ) - # Add pH calibration data if this is a pH sensor - elif self.description.key == "ph": - glp_data = self.coordinator.readings.get("messages", {}).get("glp", {}) - attrs.update( - { - "last_calibration": glp_data.get("pHDateTime"), - "offset": glp_data.get("pHOffset"), - "slope": glp_data.get("pHSlope"), - "calibration_point_1_ph": glp_data.get("pH1"), - "calibration_point_1_mv": glp_data.get("mV1"), - "calibration_point_1_temperature": glp_data.get("temp1"), - "calibration_point_2_ph": glp_data.get("pH2"), - "calibration_point_2_mv": glp_data.get("mV2"), - "calibration_point_2_temperature": glp_data.get("temp2"), - } - ) - - return attrs - - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - value = self.coordinator.get_parameter_value(self.description.key) - if value is not None: - self._attr_native_value = value - self.async_write_ha_state() - - -class HannaStatusSensor(HannaSensor): - """Representation of a Hanna status sensor.""" - - def __init__( - self, coordinator: HannaDataCoordinator, description: SensorEntityDescription - ) -> None: - """Initialize a Hanna status sensor.""" - super().__init__(coordinator, description) - self._attr_native_value = coordinator.get_messages_value("status").get( - self.description.key - ) - - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return additional state attributes.""" - return {"last_updated": self.coordinator.get_last_update_time()} - - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - value = self.coordinator.get_messages_value("status").get(self.description.key) - if value is not None: - self._attr_native_value = value - self.async_write_ha_state() + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.get_parameter_value(self.description.key) class HannaAlarmSensor(HannaSensor): """Representation of a Hanna alarm sensor.""" - def __init__( - self, - coordinator: HannaDataCoordinator, - description: SensorEntityDescription, - ) -> None: - """Initialize a Hanna alarm sensor.""" - super().__init__(coordinator, description) - self._attr_native_value = self._get_alarm_state(coordinator.readings) - - def _get_alarm_state(self, readings: dict) -> str: - """Get the current alarm state.""" - alarms = readings.get("messages", {}).get("alarms", []) - if not alarms: - return "No Alarms" - return ", ".join(alarms) - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return additional state attributes.""" - return { - "last_updated": self.coordinator.get_last_update_time(), - "alarms": self.coordinator.get_messages_value("alarms"), - "warnings": self.coordinator.get_messages_value("warnings"), - "errors": self.coordinator.get_messages_value("errors"), - } - - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - value = self._get_alarm_state(self.coordinator.readings) - if value is not None: - self._attr_native_value = value - self.async_write_ha_state() + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + alarms = self.coordinator.get_all_alarms() + if not alarms: + return "No alarms" + return ", ".join(sorted(alarms)) diff --git a/homeassistant/components/hanna/strings.json b/homeassistant/components/hanna/strings.json index 6d843bd3a124e..16ebd76c76daa 100644 --- a/homeassistant/components/hanna/strings.json +++ b/homeassistant/components/hanna/strings.json @@ -4,15 +4,11 @@ "user": { "data": { "email": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]", - "code": "Encryption key", - "scan_interval": "Scan interval" + "password": "[%key:common::config_flow::data::password%]" }, "data_description": { "email": "Email address for your Hanna Cloud account", - "password": "Password for your Hanna Cloud account", - "code": "Encryption key for your Hanna Cloud account", - "scan_interval": "Scan interval for your Hanna Cloud account. Default is 1 minute." + "password": "Password for your Hanna Cloud account" }, "description": "Enter your Hanna Cloud credentials", "title": "Hanna Cloud" diff --git a/requirements_all.txt b/requirements_all.txt index 27328f6f435e5..90f1b0833af1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.hanna -hanna-cloud==0.0.3 +hanna-cloud==0.0.5 # homeassistant.components.cloud hass-nabucasa==0.102.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e94833ce902e..e9b411b67bb4b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -982,7 +982,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.hanna -hanna-cloud==0.0.3 +hanna-cloud==0.0.5 # homeassistant.components.cloud hass-nabucasa==0.102.0 From fae064db257f2657a23ae80cbe6c2e13ffed3c48 Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Tue, 16 Sep 2025 11:26:13 +0000 Subject: [PATCH 08/26] WIP --- homeassistant/components/hanna/quality_scale.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/hanna/quality_scale.yaml b/homeassistant/components/hanna/quality_scale.yaml index 8c346a24226c7..2d6813316122b 100644 --- a/homeassistant/components/hanna/quality_scale.yaml +++ b/homeassistant/components/hanna/quality_scale.yaml @@ -4,8 +4,6 @@ rules: status: done appropriate-polling: status: done - comment: | - This integration has a default polling interval of 5 minutes. This is coherent with the HannaCloud that pulls data every 15 minutes. brands: status: done comment: | From bcec574210cd062ac2132ed62cc872c85bca424d Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Wed, 1 Oct 2025 14:36:41 +0000 Subject: [PATCH 09/26] Various updates following PR review --- homeassistant/components/hanna/__init__.py | 10 ++--- homeassistant/components/hanna/config_flow.py | 1 - homeassistant/components/hanna/const.py | 15 ++----- homeassistant/components/hanna/coordinator.py | 45 ++++++------------- homeassistant/components/hanna/entity.py | 31 +++++++++++++ homeassistant/components/hanna/sensor.py | 10 +---- 6 files changed, 55 insertions(+), 57 deletions(-) create mode 100644 homeassistant/components/hanna/entity.py diff --git a/homeassistant/components/hanna/__init__.py b/homeassistant/components/hanna/__init__.py index dcc772297d252..5b40b2781b6bc 100644 --- a/homeassistant/components/hanna/__init__.py +++ b/homeassistant/components/hanna/__init__.py @@ -7,8 +7,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import HannaConfigEntry -from .coordinator import HannaDataCoordinator +from .const import CONF_EMAIL, CONF_PASSWORD +from .coordinator import HannaConfigEntry, HannaDataCoordinator PLATFORMS = [Platform.SENSOR] @@ -30,14 +30,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> boo devices = await hass.async_add_executor_job( _authenticate_and_get_devices, api_client, - entry.data["email"], - entry.data["password"], + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], ) # Create device coordinators device_coordinators = {} for device in devices: - coordinator = HannaDataCoordinator(hass, entry, device) + coordinator = HannaDataCoordinator(hass, entry, device, api_client) await coordinator.async_config_entry_first_refresh() device_coordinators[coordinator.device_identifier] = coordinator diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py index 200a80d979cd7..0696df054b9c9 100644 --- a/homeassistant/components/hanna/config_flow.py +++ b/homeassistant/components/hanna/config_flow.py @@ -46,7 +46,6 @@ async def async_step_user( _LOGGER.warning("Connection timeout or error during Hanna authentication") errors["base"] = "cannot_connect" except AuthenticationError: - _LOGGER.warning("Authentication failed for user %s", user_input[CONF_EMAIL]) errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected error during Hanna authentication") diff --git a/homeassistant/components/hanna/const.py b/homeassistant/components/hanna/const.py index bbf4d41830142..bd73dec522ce3 100644 --- a/homeassistant/components/hanna/const.py +++ b/homeassistant/components/hanna/const.py @@ -1,16 +1,7 @@ """Constants for the Hanna integration.""" -from typing import TYPE_CHECKING - -from homeassistant.config_entries import ConfigEntry - -if TYPE_CHECKING: - from .coordinator import HannaDataCoordinator - DOMAIN = "hanna" - -class HannaConfigEntry(ConfigEntry): - """Config entry for Hanna integration with typed runtime data.""" - - runtime_data: dict[str, "HannaDataCoordinator"] | None +# Config entry data keys +CONF_EMAIL = "email" +CONF_PASSWORD = "password" diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py index 60d359e9e0947..5057638d3bb93 100644 --- a/homeassistant/components/hanna/coordinator.py +++ b/homeassistant/components/hanna/coordinator.py @@ -13,9 +13,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN + + +class HannaConfigEntry(ConfigEntry): + """Config entry for Hanna integration with typed runtime data.""" + + runtime_data: dict[str, "HannaDataCoordinator"] | None + + _LOGGER = logging.getLogger(__name__) @@ -26,47 +34,25 @@ def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, - device: dict, + device: dict[str, Any], + api_client: HannaCloudClient, ) -> None: """Initialize the Hanna data coordinator.""" - self.api_client = HannaCloudClient() + self.api_client = api_client self.readings = None self.device_data = device - self._email = config_entry.data["email"] - self._password = config_entry.data["password"] super().__init__( hass, _LOGGER, - name=f"hanna_{self.device_identifier}", + name=f"{DOMAIN}_{self.device_identifier}", update_interval=timedelta(seconds=30), ) - self._authenticated = False - - async def ensure_authenticated(self) -> None: - """Ensure the client is authenticated with the Hanna API.""" - if not self._authenticated: - await self.hass.async_add_executor_job( - self.api_client.authenticate, self._email, self._password - ) - self._authenticated = True @property def device_identifier(self) -> str: """Return the device identifier.""" return self.device_data["DID"] - @property - def device_info(self) -> DeviceInfo: - """Return device information for Home Assistant.""" - return DeviceInfo( - identifiers={("hanna", self.device_identifier)}, - manufacturer=self.device_data.get("manufacturer"), - model=self.device_data.get("DM"), - name=f"{self.device_identifier} {self.device_data.get('name')}", - serial_number=self.device_data.get("serial_number"), - sw_version=self.device_data.get("sw_version"), - ) - def get_all_alarms(self) -> list[str]: """Get all alarms from the sensor data.""" return ( @@ -87,7 +73,6 @@ def get_parameter_value(self, key: str) -> Any: async def _async_update_data(self): """Fetch latest sensor data from the Hanna API.""" try: - await self.ensure_authenticated() readings = await self.hass.async_add_executor_job( self.api_client.get_last_device_reading, self.device_identifier ) @@ -96,6 +81,4 @@ async def _async_update_data(self): raise UpdateFailed(f"Error communicating with Hanna API: {e}") from e except (KeyError, IndexError) as e: raise UpdateFailed(f"Error parsing Hanna API response: {e}") from e - except Exception as e: - _LOGGER.error("Unexpected error while fetching Hanna data: %s", e) - raise UpdateFailed(f"Unexpected error: {e}") from e + return readings diff --git a/homeassistant/components/hanna/entity.py b/homeassistant/components/hanna/entity.py new file mode 100644 index 0000000000000..2712397b4c651 --- /dev/null +++ b/homeassistant/components/hanna/entity.py @@ -0,0 +1,31 @@ +"""Hanna Instruments entity base class for Home Assistant. + +This module provides the base entity class for Hanna Instruments entities. +""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HannaDataCoordinator + + +class HannaEntity(CoordinatorEntity[HannaDataCoordinator], Entity): + """Base class for Hanna entities.""" + + def __init__(self, coordinator: HannaDataCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + @property + def device_info(self) -> DeviceInfo: + """Return device information for Home Assistant.""" + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.device_identifier)}, + manufacturer=self.coordinator.device_data.get("manufacturer"), + model=self.coordinator.device_data.get("DM"), + name=self.coordinator.device_data.get("name"), + serial_number=self.coordinator.device_data.get("serial_number"), + sw_version=self.coordinator.device_data.get("sw_version"), + ) diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py index 538a8b29204b8..e6c612dab04d6 100644 --- a/homeassistant/components/hanna/sensor.py +++ b/homeassistant/components/hanna/sensor.py @@ -18,12 +18,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import HannaDataCoordinator +from .entity import HannaEntity _LOGGER = logging.getLogger(__name__) @@ -97,7 +96,7 @@ async def async_setup_entry( async_add_entities(all_entities) -class HannaSensor(CoordinatorEntity[HannaDataCoordinator], SensorEntity): +class HannaSensor(HannaEntity, SensorEntity): """Representation of a Hanna sensor.""" def __init__( @@ -116,11 +115,6 @@ def __init__( self._attr_should_poll = False self.description = description - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return self.coordinator.device_info - class HannaParamSensor(HannaSensor): """Representation of a Hanna sensor.""" From 1746628084329833e7f1cde018eea93286be5902 Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Tue, 14 Oct 2025 08:00:50 +0000 Subject: [PATCH 10/26] Remove some logging --- homeassistant/components/hanna/config_flow.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py index 0696df054b9c9..22da5729c5b1f 100644 --- a/homeassistant/components/hanna/config_flow.py +++ b/homeassistant/components/hanna/config_flow.py @@ -43,13 +43,9 @@ async def async_step_user( client.authenticate, user_input[CONF_EMAIL], user_input[CONF_PASSWORD] ) except (Timeout, RequestsConnectionError): - _LOGGER.warning("Connection timeout or error during Hanna authentication") errors["base"] = "cannot_connect" except AuthenticationError: errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected error during Hanna authentication") - errors["base"] = "unknown" if errors: return self.async_show_form( From 719b2c975387f76314e343eca3defcf9d0906f37 Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Tue, 14 Oct 2025 08:07:57 +0000 Subject: [PATCH 11/26] update the type annotation --- homeassistant/components/hanna/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py index 5057638d3bb93..5eaec63db406e 100644 --- a/homeassistant/components/hanna/coordinator.py +++ b/homeassistant/components/hanna/coordinator.py @@ -70,7 +70,7 @@ def get_parameter_value(self, key: str) -> Any: return parameter["value"] return None - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, Any]: """Fetch latest sensor data from the Hanna API.""" try: readings = await self.hass.async_add_executor_job( From 9150cbd4791c977e87adb9bf9a095de2dcbd79f0 Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Tue, 14 Oct 2025 13:49:47 +0000 Subject: [PATCH 12/26] Update following PR review --- homeassistant/components/hanna/__init__.py | 3 +- homeassistant/components/hanna/config_flow.py | 48 +++++++------- homeassistant/components/hanna/coordinator.py | 15 +++-- homeassistant/components/hanna/sensor.py | 62 +++++-------------- tests/components/hanna/test_config_flow.py | 12 +--- 5 files changed, 53 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/hanna/__init__.py b/homeassistant/components/hanna/__init__.py index 5b40b2781b6bc..43907c1a92160 100644 --- a/homeassistant/components/hanna/__init__.py +++ b/homeassistant/components/hanna/__init__.py @@ -25,7 +25,6 @@ def _authenticate_and_get_devices( async def async_setup_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool: """Set up Hanna Instruments from a config entry.""" - # Create a temporary API client to discover devices api_client = HannaCloudClient() devices = await hass.async_add_executor_job( _authenticate_and_get_devices, @@ -54,7 +53,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bo # Unload platforms unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - # Clean up and device coordinators + # Unload coordinators if unload_ok and entry.runtime_data is not None: for coordinator in entry.runtime_data.values(): await coordinator.async_shutdown() diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py index 22da5729c5b1f..f7f52e0bc3f23 100644 --- a/homeassistant/components/hanna/config_flow.py +++ b/homeassistant/components/hanna/config_flow.py @@ -29,32 +29,30 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the setup flow.""" - if user_input is None: - return self.async_show_form( - step_id="user", - data_schema=self.data_schema, - ) errors: dict[str, str] = {} - try: - client = HannaCloudClient() - await self.hass.async_add_executor_job( - client.authenticate, user_input[CONF_EMAIL], user_input[CONF_PASSWORD] - ) - except (Timeout, RequestsConnectionError): - errors["base"] = "cannot_connect" - except AuthenticationError: - errors["base"] = "invalid_auth" - - if errors: - return self.async_show_form( - step_id="user", - data_schema=self.data_schema, - errors=errors, - ) - - return self.async_create_entry( - title=user_input[CONF_EMAIL], - data=user_input, + if user_input is not None: + try: + client = HannaCloudClient() + await self.hass.async_add_executor_job( + client.authenticate, + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + ) + except (Timeout, RequestsConnectionError): + errors["base"] = "cannot_connect" + except AuthenticationError: + errors["base"] = "invalid_auth" + + if not errors: + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.data_schema, + errors=errors, ) diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py index 5eaec63db406e..2d4fa364a8b2f 100644 --- a/homeassistant/components/hanna/coordinator.py +++ b/homeassistant/components/hanna/coordinator.py @@ -53,11 +53,16 @@ def device_identifier(self) -> str: """Return the device identifier.""" return self.device_data["DID"] - def get_all_alarms(self) -> list[str]: - """Get all alarms from the sensor data.""" - return ( - self.api_client.alarms + self.api_client.errors + self.api_client.warnings - ) + def get_alerts(self, alert_types: str) -> list[str]: + """Get all alerts from the sensor data.""" + alerts = [] + if alert_types == "alarms": + alerts.extend(self.api_client.alarms) + if alert_types == "errors": + alerts.extend(self.api_client.errors) + if alert_types == "warnings": + alerts.extend(self.api_client.warnings) + return alerts def get_parameters(self) -> list[dict[str, Any]]: """Get all parameters from the sensor data.""" diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py index e6c612dab04d6..b38049908d94e 100644 --- a/homeassistant/components/hanna/sensor.py +++ b/homeassistant/components/hanna/sensor.py @@ -60,11 +60,6 @@ "cl": SensorEntityDescription( key="cl", name="Chlorine flow rate", icon="mdi:chemical-weapon" ), - "alarms": SensorEntityDescription( - key="alarms", - name="Alarms", - icon="mdi:alert", - ), } @@ -76,71 +71,46 @@ async def async_setup_entry( """Set up Hanna sensors from a config entry.""" device_coordinators = entry.runtime_data - # Collect all entities during initialization - all_entities: list[HannaParamSensor | HannaAlarmSensor] = [] + entities: list[HannaSensor] = [] for coordinator in device_coordinators.values(): - # Add parameter sensors - for parameter in coordinator.get_parameters(): - if description := SENSOR_DESCRIPTIONS.get(parameter["name"]): - all_entities.append(HannaParamSensor(coordinator, description)) - else: - _LOGGER.warning("No sensor description found for %s", parameter["name"]) - - # Add alarms sensor - all_entities.append( - HannaAlarmSensor(coordinator, SENSOR_DESCRIPTIONS["alarms"]) + entities.extend( + [ + HannaSensor(coordinator, description) + for parameter in coordinator.get_parameters() + if (description := SENSOR_DESCRIPTIONS.get(parameter["name"])) + ] ) - if all_entities: - async_add_entities(all_entities) + if entities: + async_add_entities(entities) class HannaSensor(HannaEntity, SensorEntity): """Representation of a Hanna sensor.""" def __init__( - self, coordinator: HannaDataCoordinator, description: SensorEntityDescription + self, + coordinator: HannaDataCoordinator, + description: SensorEntityDescription, ) -> None: """Initialize a Hanna sensor.""" super().__init__(coordinator) self._attr_unique_id = f"{coordinator.device_identifier}_{description.key}" + self._attr_has_entity_name = True + self._attr_should_poll = False + self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_name = ( None if description.name is None or description.name is UNDEFINED else description.name ) self._attr_icon = description.icon - self._attr_has_entity_name = True - self._attr_should_poll = False - self.description = description - - -class HannaParamSensor(HannaSensor): - """Representation of a Hanna sensor.""" - - def __init__( - self, coordinator: HannaDataCoordinator, description: SensorEntityDescription - ) -> None: - """Initialize a Hanna sensor.""" - super().__init__(coordinator, description) self._attr_native_unit_of_measurement = description.native_unit_of_measurement - self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_device_class = description.device_class + self.description = description @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" return self.coordinator.get_parameter_value(self.description.key) - - -class HannaAlarmSensor(HannaSensor): - """Representation of a Hanna alarm sensor.""" - - @property - def native_value(self) -> StateType: - """Return the value reported by the sensor.""" - alarms = self.coordinator.get_all_alarms() - if not alarms: - return "No alarms" - return ", ".join(sorted(alarms)) diff --git a/tests/components/hanna/test_config_flow.py b/tests/components/hanna/test_config_flow.py index 6de74cf0206ea..96aeb0ea04706 100644 --- a/tests/components/hanna/test_config_flow.py +++ b/tests/components/hanna/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, MagicMock -from requests.exceptions import RequestException +from hanna_cloud import AuthenticationError from homeassistant.components.hanna.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -28,7 +28,6 @@ async def test_full_flow( { "email": "test@example.com", "password": "test-password", - "code": "test-code", }, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -36,7 +35,6 @@ async def test_full_flow( assert result["data"] == { "email": "test@example.com", "password": "test-password", - "code": "test-code", } @@ -47,7 +45,7 @@ async def test_invalid_auth( ) -> None: """Test invalid authentication.""" # Create a RequestException with 401 status code to simulate authentication failure - auth_error = RequestException("Authentication failed") + auth_error = AuthenticationError("Authentication failed") auth_error.response = MagicMock() auth_error.response.status_code = 401 @@ -62,11 +60,7 @@ async def test_invalid_auth( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "email": "test@example.com", - "password": "test-password", - "code": "test-code", - }, + {"email": "test@example.com", "password": "test-password"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" From f095ccef840df0e7693cded041cb068d2ed0d461 Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Tue, 14 Oct 2025 13:53:17 +0000 Subject: [PATCH 13/26] update looping on Sensor Description --- homeassistant/components/hanna/sensor.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py index b38049908d94e..af9c26e445a86 100644 --- a/homeassistant/components/hanna/sensor.py +++ b/homeassistant/components/hanna/sensor.py @@ -26,41 +26,41 @@ _LOGGER = logging.getLogger(__name__) -SENSOR_DESCRIPTIONS = { - "ph": SensorEntityDescription( +SENSOR_DESCRIPTIONS = [ + SensorEntityDescription( key="ph", name="pH value", icon="mdi:flask", device_class=SensorDeviceClass.PH, ), - "orp": SensorEntityDescription( + SensorEntityDescription( key="orp", name="Chlorine ORP value", icon="mdi:flask", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, ), - "temp": SensorEntityDescription( + SensorEntityDescription( key="temp", name="Water temperature", icon="mdi:thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), - "airTemp": SensorEntityDescription( + SensorEntityDescription( key="airTemp", name="Air temperature", icon="mdi:thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), - "acidBase": SensorEntityDescription( + SensorEntityDescription( key="acidBase", name="pH Acid/Base flow rate", icon="mdi:chemical-weapon" ), - "cl": SensorEntityDescription( + SensorEntityDescription( key="cl", name="Chlorine flow rate", icon="mdi:chemical-weapon" ), -} +] async def async_setup_entry( @@ -77,8 +77,7 @@ async def async_setup_entry( entities.extend( [ HannaSensor(coordinator, description) - for parameter in coordinator.get_parameters() - if (description := SENSOR_DESCRIPTIONS.get(parameter["name"])) + for description in SENSOR_DESCRIPTIONS ] ) From 6b30df688f72f9c5818f7a6db9b725203c57a3cf Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Tue, 14 Oct 2025 14:36:12 +0000 Subject: [PATCH 14/26] Removed dead code --- homeassistant/components/hanna/coordinator.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py index 2d4fa364a8b2f..2a5366b58d2d1 100644 --- a/homeassistant/components/hanna/coordinator.py +++ b/homeassistant/components/hanna/coordinator.py @@ -53,17 +53,6 @@ def device_identifier(self) -> str: """Return the device identifier.""" return self.device_data["DID"] - def get_alerts(self, alert_types: str) -> list[str]: - """Get all alerts from the sensor data.""" - alerts = [] - if alert_types == "alarms": - alerts.extend(self.api_client.alarms) - if alert_types == "errors": - alerts.extend(self.api_client.errors) - if alert_types == "warnings": - alerts.extend(self.api_client.warnings) - return alerts - def get_parameters(self) -> list[dict[str, Any]]: """Get all parameters from the sensor data.""" return self.api_client.parameters From 4512afd930cc65b43cd1359225028f2ff2e523ad Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Tue, 14 Oct 2025 14:44:47 +0000 Subject: [PATCH 15/26] self.add_suggested_values_to_schema(self.data_schema, user_input) --- homeassistant/components/hanna/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py index f7f52e0bc3f23..7dc3b8120b5e6 100644 --- a/homeassistant/components/hanna/config_flow.py +++ b/homeassistant/components/hanna/config_flow.py @@ -53,6 +53,8 @@ async def async_step_user( return self.async_show_form( step_id="user", - data_schema=self.data_schema, + data_schema=self.add_suggested_values_to_schema( + self.data_schema, user_input + ), errors=errors, ) From 2bc1b9b49654c5686789c133e58ba40c5fc8087a Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Wed, 15 Oct 2025 06:47:36 +0000 Subject: [PATCH 16/26] Update quality scale --- homeassistant/components/hanna/quality_scale.yaml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/hanna/quality_scale.yaml b/homeassistant/components/hanna/quality_scale.yaml index 2d6813316122b..3de2eaa09ad41 100644 --- a/homeassistant/components/hanna/quality_scale.yaml +++ b/homeassistant/components/hanna/quality_scale.yaml @@ -1,22 +1,18 @@ rules: # Bronze action-setup: - status: done + status: exempt + comment: | + This integration doesn't add actions. appropriate-polling: status: done - brands: - status: done - comment: | - PR: https://github.com/home-assistant/brands/pull/7244 + brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: done - docs-high-level-description: - status: done - comment: | - PR: https://github.com/home-assistant/home-assistant.io/pull/39614 + docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done entity-event-setup: From 74b85d92050e80eb2437282e96c080625b05c057 Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Wed, 15 Oct 2025 09:58:07 +0000 Subject: [PATCH 17/26] Update quality scale and define async_shutdown function in coordinator that was already referenced --- homeassistant/components/hanna/coordinator.py | 19 ++++++++++++++++++- .../components/hanna/quality_scale.yaml | 13 ++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py index 2a5366b58d2d1..62499a357fd76 100644 --- a/homeassistant/components/hanna/coordinator.py +++ b/homeassistant/components/hanna/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging -from typing import Any +from typing import Any, Literal from hanna_cloud import HannaCloudClient from requests.exceptions import RequestException @@ -53,6 +53,19 @@ def device_identifier(self) -> str: """Return the device identifier.""" return self.device_data["DID"] + def get_alerts( + self, alert_types: Literal["alarms", "errors", "warnings"] + ) -> list[str]: + """Get all alerts from the sensor data.""" + alerts = [] + if alert_types == "alarms": + alerts.extend(self.api_client.alarms) + if alert_types == "errors": + alerts.extend(self.api_client.errors) + if alert_types == "warnings": + alerts.extend(self.api_client.warnings) + return alerts + def get_parameters(self) -> list[dict[str, Any]]: """Get all parameters from the sensor data.""" return self.api_client.parameters @@ -76,3 +89,7 @@ async def _async_update_data(self) -> dict[str, Any]: except (KeyError, IndexError) as e: raise UpdateFailed(f"Error parsing Hanna API response: {e}") from e return readings + + async def async_shutdown(self) -> None: + """Shutdown the Hanna data coordinator.""" + del self.api_client diff --git a/homeassistant/components/hanna/quality_scale.yaml b/homeassistant/components/hanna/quality_scale.yaml index 3de2eaa09ad41..5983cf68afd91 100644 --- a/homeassistant/components/hanna/quality_scale.yaml +++ b/homeassistant/components/hanna/quality_scale.yaml @@ -34,11 +34,14 @@ rules: # Silver action-exceptions: todo - config-entry-unloading: todo - docs-configuration-parameters: todo - docs-installation-parameters: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have any configuration parameters. + docs-installation-parameters: done entity-unavailable: todo - integration-owner: todo + integration-owner: done log-when-unavailable: todo parallel-updates: todo reauthentication-flow: todo @@ -58,7 +61,7 @@ rules: docs-use-cases: todo dynamic-devices: todo entity-category: todo - entity-device-class: todo + entity-device-class: done entity-disabled-by-default: todo entity-translations: todo exception-translations: todo From caf9b9d2ef4526080ca779a8401997ffc1d1191c Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Wed, 15 Oct 2025 11:33:38 +0000 Subject: [PATCH 18/26] removed dead code and async_shutdown function in coordinator as it is inherited --- homeassistant/components/hanna/coordinator.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py index 62499a357fd76..2a5366b58d2d1 100644 --- a/homeassistant/components/hanna/coordinator.py +++ b/homeassistant/components/hanna/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging -from typing import Any, Literal +from typing import Any from hanna_cloud import HannaCloudClient from requests.exceptions import RequestException @@ -53,19 +53,6 @@ def device_identifier(self) -> str: """Return the device identifier.""" return self.device_data["DID"] - def get_alerts( - self, alert_types: Literal["alarms", "errors", "warnings"] - ) -> list[str]: - """Get all alerts from the sensor data.""" - alerts = [] - if alert_types == "alarms": - alerts.extend(self.api_client.alarms) - if alert_types == "errors": - alerts.extend(self.api_client.errors) - if alert_types == "warnings": - alerts.extend(self.api_client.warnings) - return alerts - def get_parameters(self) -> list[dict[str, Any]]: """Get all parameters from the sensor data.""" return self.api_client.parameters @@ -89,7 +76,3 @@ async def _async_update_data(self) -> dict[str, Any]: except (KeyError, IndexError) as e: raise UpdateFailed(f"Error parsing Hanna API response: {e}") from e return readings - - async def async_shutdown(self) -> None: - """Shutdown the Hanna data coordinator.""" - del self.api_client From 8d882cffe8a27683aee750ff61957312e7d7661b Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Thu, 16 Oct 2025 13:02:12 +0000 Subject: [PATCH 19/26] Extend test coverage --- tests/components/hanna/test_config_flow.py | 56 ++++++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/tests/components/hanna/test_config_flow.py b/tests/components/hanna/test_config_flow.py index 96aeb0ea04706..8428be74c38c3 100644 --- a/tests/components/hanna/test_config_flow.py +++ b/tests/components/hanna/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from hanna_cloud import AuthenticationError +from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout from homeassistant.components.hanna.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -38,18 +39,14 @@ async def test_full_flow( } -async def test_invalid_auth( +async def _test_error_scenario( hass: HomeAssistant, - mock_setup_entry: AsyncMock, mock_hanna_client: MagicMock, + exception: Exception, + expected_error: str, ) -> None: - """Test invalid authentication.""" - # Create a RequestException with 401 status code to simulate authentication failure - auth_error = AuthenticationError("Authentication failed") - auth_error.response = MagicMock() - auth_error.response.status_code = 401 - - mock_hanna_client.authenticate.side_effect = auth_error + """Test a specific error scenario in the config flow.""" + mock_hanna_client.authenticate.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, @@ -64,4 +61,43 @@ async def test_invalid_auth( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": expected_error} + + +async def test_invalid_auth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hanna_client: MagicMock, +) -> None: + """Test invalid authentication.""" + # Create a RequestException with 401 status code to simulate authentication failure + auth_error = AuthenticationError("Authentication failed") + auth_error.response = MagicMock() + auth_error.response.status_code = 401 + + await _test_error_scenario(hass, mock_hanna_client, auth_error, "invalid_auth") + + +async def test_cannot_connect_timeout( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hanna_client: MagicMock, +) -> None: + """Test connection timeout error.""" + await _test_error_scenario( + hass, mock_hanna_client, Timeout("Connection timeout"), "cannot_connect" + ) + + +async def test_cannot_connect_connection_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hanna_client: MagicMock, +) -> None: + """Test connection error.""" + await _test_error_scenario( + hass, + mock_hanna_client, + RequestsConnectionError("Connection failed"), + "cannot_connect", + ) From e458b79869aab0c53af1063b5838c750e43447ea Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Thu, 16 Oct 2025 15:06:13 +0000 Subject: [PATCH 20/26] Improve test_error_scenarios following review --- tests/components/hanna/test_config_flow.py | 67 +++++++++------------- 1 file changed, 26 insertions(+), 41 deletions(-) diff --git a/tests/components/hanna/test_config_flow.py b/tests/components/hanna/test_config_flow.py index 8428be74c38c3..353aaf832f342 100644 --- a/tests/components/hanna/test_config_flow.py +++ b/tests/components/hanna/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from hanna_cloud import AuthenticationError +import pytest from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout from homeassistant.components.hanna.const import DOMAIN @@ -39,13 +40,36 @@ async def test_full_flow( } -async def _test_error_scenario( +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + ( + AuthenticationError("Authentication failed"), + "invalid_auth", + ), + ( + Timeout("Connection timeout"), + "cannot_connect", + ), + ( + RequestsConnectionError("Connection failed"), + "cannot_connect", + ), + ], +) +async def test_error_scenarios( hass: HomeAssistant, + mock_setup_entry: AsyncMock, mock_hanna_client: MagicMock, exception: Exception, expected_error: str, ) -> None: - """Test a specific error scenario in the config flow.""" + """Test various error scenarios in the config flow.""" + # Set up the authentication error response for AuthenticationError + if isinstance(exception, AuthenticationError): + exception.response = MagicMock() + exception.response.status_code = 401 + mock_hanna_client.authenticate.side_effect = exception result = await hass.config_entries.flow.async_init( @@ -62,42 +86,3 @@ async def _test_error_scenario( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": expected_error} - - -async def test_invalid_auth( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_hanna_client: MagicMock, -) -> None: - """Test invalid authentication.""" - # Create a RequestException with 401 status code to simulate authentication failure - auth_error = AuthenticationError("Authentication failed") - auth_error.response = MagicMock() - auth_error.response.status_code = 401 - - await _test_error_scenario(hass, mock_hanna_client, auth_error, "invalid_auth") - - -async def test_cannot_connect_timeout( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_hanna_client: MagicMock, -) -> None: - """Test connection timeout error.""" - await _test_error_scenario( - hass, mock_hanna_client, Timeout("Connection timeout"), "cannot_connect" - ) - - -async def test_cannot_connect_connection_error( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_hanna_client: MagicMock, -) -> None: - """Test connection error.""" - await _test_error_scenario( - hass, - mock_hanna_client, - RequestsConnectionError("Connection failed"), - "cannot_connect", - ) From b384d1d4df3fc6ef4e2bbf7f270c8267e460e8ab Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Thu, 16 Oct 2025 15:39:03 +0000 Subject: [PATCH 21/26] Bump to hanna-cloud 0.0.6 --- homeassistant/components/hanna/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hanna/manifest.json b/homeassistant/components/hanna/manifest.json index 9c337ec69df38..7e5138e1e4ecd 100644 --- a/homeassistant/components/hanna/manifest.json +++ b/homeassistant/components/hanna/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hanna", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["hanna-cloud==0.0.5"], + "requirements": ["hanna-cloud==0.0.6"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index a06109481ffa9..a230fba187eaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1151,7 +1151,7 @@ habiticalib==0.4.5 habluetooth==5.7.0 # homeassistant.components.hanna -hanna-cloud==0.0.5 +hanna-cloud==0.0.6 # homeassistant.components.cloud hass-nabucasa==1.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9f30c0fd0a5f..faf1448e11038 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1012,7 +1012,7 @@ habiticalib==0.4.5 habluetooth==5.7.0 # homeassistant.components.hanna -hanna-cloud==0.0.5 +hanna-cloud==0.0.6 # homeassistant.components.cloud hass-nabucasa==1.3.0 From d3bcb74e9a99e0cbc8685b081e72a834521f0689 Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Wed, 12 Nov 2025 20:19:42 +0000 Subject: [PATCH 22/26] Following review comments --- homeassistant/components/hanna/__init__.py | 10 +-- homeassistant/components/hanna/config_flow.py | 5 +- homeassistant/components/hanna/coordinator.py | 10 +-- homeassistant/components/hanna/entity.py | 23 +++---- homeassistant/components/hanna/manifest.json | 3 +- .../components/hanna/quality_scale.yaml | 20 +++--- homeassistant/components/hanna/sensor.py | 60 +++++++++--------- homeassistant/components/hanna/strings.json | 22 +++++++ homeassistant/generated/integrations.json | 3 +- tests/components/hanna/conftest.py | 8 +-- tests/components/hanna/test_config_flow.py | 63 ++++++++++++++++--- 11 files changed, 134 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/hanna/__init__.py b/homeassistant/components/hanna/__init__.py index 43907c1a92160..1bdb00f9335a9 100644 --- a/homeassistant/components/hanna/__init__.py +++ b/homeassistant/components/hanna/__init__.py @@ -50,12 +50,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> boo async def async_unload_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool: """Unload a config entry.""" - # Unload platforms - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - # Unload coordinators - if unload_ok and entry.runtime_data is not None: - for coordinator in entry.runtime_data.values(): - await coordinator.async_shutdown() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py index 7dc3b8120b5e6..60aa62383c343 100644 --- a/homeassistant/components/hanna/config_flow.py +++ b/homeassistant/components/hanna/config_flow.py @@ -33,8 +33,8 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: + client = HannaCloudClient() try: - client = HannaCloudClient() await self.hass.async_add_executor_job( client.authenticate, user_input[CONF_EMAIL], @@ -46,6 +46,9 @@ async def async_step_user( errors["base"] = "invalid_auth" if not errors: + identifier = user_input[CONF_EMAIL] + await self.async_set_unique_id(identifier) + self._abort_if_unique_id_configured() return self.async_create_entry( title=user_input[CONF_EMAIL], data=user_input, diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py index 2a5366b58d2d1..3caa759f13140 100644 --- a/homeassistant/components/hanna/coordinator.py +++ b/homeassistant/components/hanna/coordinator.py @@ -17,12 +17,7 @@ from .const import DOMAIN - -class HannaConfigEntry(ConfigEntry): - """Config entry for Hanna integration with typed runtime data.""" - - runtime_data: dict[str, "HannaDataCoordinator"] | None - +type HannaConfigEntry = ConfigEntry[dict[str, HannaDataCoordinator]] _LOGGER = logging.getLogger(__name__) @@ -33,7 +28,7 @@ class HannaDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HannaConfigEntry, device: dict[str, Any], api_client: HannaCloudClient, ) -> None: @@ -70,7 +65,6 @@ async def _async_update_data(self) -> dict[str, Any]: readings = await self.hass.async_add_executor_job( self.api_client.get_last_device_reading, self.device_identifier ) - self.readings = readings except RequestException as e: raise UpdateFailed(f"Error communicating with Hanna API: {e}") from e except (KeyError, IndexError) as e: diff --git a/homeassistant/components/hanna/entity.py b/homeassistant/components/hanna/entity.py index 2712397b4c651..3de5723583a81 100644 --- a/homeassistant/components/hanna/entity.py +++ b/homeassistant/components/hanna/entity.py @@ -4,28 +4,25 @@ """ from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import HannaDataCoordinator -class HannaEntity(CoordinatorEntity[HannaDataCoordinator], Entity): +class HannaEntity(CoordinatorEntity[HannaDataCoordinator]): """Base class for Hanna entities.""" + _attr_has_entity_name = True + def __init__(self, coordinator: HannaDataCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) - - @property - def device_info(self) -> DeviceInfo: - """Return device information for Home Assistant.""" - return DeviceInfo( - identifiers={(DOMAIN, self.coordinator.device_identifier)}, - manufacturer=self.coordinator.device_data.get("manufacturer"), - model=self.coordinator.device_data.get("DM"), - name=self.coordinator.device_data.get("name"), - serial_number=self.coordinator.device_data.get("serial_number"), - sw_version=self.coordinator.device_data.get("sw_version"), + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_identifier)}, + manufacturer=coordinator.device_data.get("manufacturer"), + model=coordinator.device_data.get("DM"), + name=coordinator.device_data.get("name"), + serial_number=coordinator.device_data.get("serial_number"), + sw_version=coordinator.device_data.get("sw_version"), ) diff --git a/homeassistant/components/hanna/manifest.json b/homeassistant/components/hanna/manifest.json index 7e5138e1e4ecd..b1e503e5e28f6 100644 --- a/homeassistant/components/hanna/manifest.json +++ b/homeassistant/components/hanna/manifest.json @@ -6,6 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hanna", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["hanna-cloud==0.0.6"], - "single_config_entry": true + "requirements": ["hanna-cloud==0.0.6"] } diff --git a/homeassistant/components/hanna/quality_scale.yaml b/homeassistant/components/hanna/quality_scale.yaml index 5983cf68afd91..f4eb96842e6d6 100644 --- a/homeassistant/components/hanna/quality_scale.yaml +++ b/homeassistant/components/hanna/quality_scale.yaml @@ -19,14 +19,8 @@ rules: status: exempt comment: | Entities of this integration does not explicitly subscribe to events. - entity-unique-id: - status: done - comment: | - This integration uses the device identifier as unique ID. The device identifier is unique for each Hanna device. - has-entity-name: - status: done - comment: | - Entities use has_entity_name = True. All Sensors have a name. + entity-unique-id: done + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -48,22 +42,22 @@ rules: test-coverage: todo # Gold - devices: todo + devices: done diagnostics: todo discovery-update-info: todo discovery: todo - docs-data-update: todo + docs-data-update: done docs-examples: todo docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo + docs-supported-devices: done + docs-supported-functions: done docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: todo entity-category: todo entity-device-class: done entity-disabled-by-default: todo - entity-translations: todo + entity-translations: done exception-translations: todo icon-translations: todo reconfiguration-flow: todo diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py index af9c26e445a86..6171068cc7c43 100644 --- a/homeassistant/components/hanna/sensor.py +++ b/homeassistant/components/hanna/sensor.py @@ -15,13 +15,12 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature +from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.typing import UNDEFINED, StateType +from homeassistant.helpers.typing import StateType -from .coordinator import HannaDataCoordinator +from .coordinator import HannaConfigEntry, HannaDataCoordinator from .entity import HannaEntity _LOGGER = logging.getLogger(__name__) @@ -29,60 +28,63 @@ SENSOR_DESCRIPTIONS = [ SensorEntityDescription( key="ph", - name="pH value", - icon="mdi:flask", device_class=SensorDeviceClass.PH, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="orp", - name="Chlorine ORP value", + translation_key="chlorine_orp_value", icon="mdi:flask", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temp", - name="Water temperature", + translation_key="water_temperature", icon="mdi:thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="airTemp", - name="Air temperature", + translation_key="air_temperature", icon="mdi:thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - key="acidBase", name="pH Acid/Base flow rate", icon="mdi:chemical-weapon" + key="acidBase", + translation_key="ph_acid_base_flow_rate", + icon="mdi:chemical-weapon", + native_unit_of_measurement=UnitOfVolume.MILLILITERS, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - key="cl", name="Chlorine flow rate", icon="mdi:chemical-weapon" + key="cl", + translation_key="chlorine_flow_rate", + icon="mdi:chemical-weapon", + native_unit_of_measurement=UnitOfVolume.MILLILITERS, + state_class=SensorStateClass.MEASUREMENT, ), ] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HannaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hanna sensors from a config entry.""" device_coordinators = entry.runtime_data - entities: list[HannaSensor] = [] - - for coordinator in device_coordinators.values(): - entities.extend( - [ - HannaSensor(coordinator, description) - for description in SENSOR_DESCRIPTIONS - ] - ) - - if entities: - async_add_entities(entities) + async_add_entities( + HannaSensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + for coordinator in device_coordinators.values() + ) class HannaSensor(HannaEntity, SensorEntity): @@ -98,15 +100,11 @@ def __init__( self._attr_unique_id = f"{coordinator.device_identifier}_{description.key}" self._attr_has_entity_name = True self._attr_should_poll = False - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_name = ( - None - if description.name is None or description.name is UNDEFINED - else description.name - ) + self._attr_translation_key = description.translation_key self._attr_icon = description.icon - self._attr_native_unit_of_measurement = description.native_unit_of_measurement + self._attr_state_class = description.state_class self._attr_device_class = description.device_class + self._attr_native_unit_of_measurement = description.native_unit_of_measurement self.description = description @property diff --git a/homeassistant/components/hanna/strings.json b/homeassistant/components/hanna/strings.json index 16ebd76c76daa..baa5ba928d8a4 100644 --- a/homeassistant/components/hanna/strings.json +++ b/homeassistant/components/hanna/strings.json @@ -22,5 +22,27 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "ph": { + "name": "pH" + }, + "chlorine_orp_value": { + "name": "Chlorine ORP value" + }, + "water_temperature": { + "name": "Water temperature" + }, + "air_temperature": { + "name": "Air temperature" + }, + "ph_acid_base_flow_rate": { + "name": "pH Acid/Base flow rate" + }, + "chlorine_flow_rate": { + "name": "Chlorine flow rate" + } + } } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a06425380a216..780122ade7bc5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2609,8 +2609,7 @@ "name": "Hanna", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "single_config_entry": true + "iot_class": "cloud_polling" }, "hardkernel": { "name": "Hardkernel", diff --git a/tests/components/hanna/conftest.py b/tests/components/hanna/conftest.py index 37927e2f2a058..a8e8641f55611 100644 --- a/tests/components/hanna/conftest.py +++ b/tests/components/hanna/conftest.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.hanna.const import DOMAIN -from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from tests.common import MockConfigEntry @@ -35,10 +35,6 @@ def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" return MockConfigEntry( domain=DOMAIN, - data={ - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - CONF_CODE: "test-code", - }, + data={CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"}, title="test@example.com", ) diff --git a/tests/components/hanna/test_config_flow.py b/tests/components/hanna/test_config_flow.py index 353aaf832f342..978fcceceeb29 100644 --- a/tests/components/hanna/test_config_flow.py +++ b/tests/components/hanna/test_config_flow.py @@ -11,6 +11,17 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + + +def _create_exception(exception_type: type[Exception], message: str) -> Exception: + """Create an exception with proper setup for AuthenticationError.""" + exception = exception_type(message) + if isinstance(exception, AuthenticationError): + exception.response = MagicMock() + exception.response.status_code = 401 + return exception + async def test_full_flow( hass: HomeAssistant, @@ -44,15 +55,15 @@ async def test_full_flow( ("exception", "expected_error"), [ ( - AuthenticationError("Authentication failed"), + _create_exception(AuthenticationError, "Authentication failed"), "invalid_auth", ), ( - Timeout("Connection timeout"), + _create_exception(Timeout, "Connection timeout"), "cannot_connect", ), ( - RequestsConnectionError("Connection failed"), + _create_exception(RequestsConnectionError, "Connection failed"), "cannot_connect", ), ], @@ -65,11 +76,6 @@ async def test_error_scenarios( expected_error: str, ) -> None: """Test various error scenarios in the config flow.""" - # Set up the authentication error response for AuthenticationError - if isinstance(exception, AuthenticationError): - exception.response = MagicMock() - exception.response.status_code = 401 - mock_hanna_client.authenticate.side_effect = exception result = await hass.config_entries.flow.async_init( @@ -86,3 +92,44 @@ async def test_error_scenarios( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": expected_error} + + # Repatch to succeed and complete the flow + mock_hanna_client.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "test@example.com", "password": "test-password"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == { + "email": "test@example.com", + "password": "test-password", + } + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hanna_client: MagicMock, +) -> None: + """Test that duplicate entries are aborted.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"email": "test@example.com", "password": "test-password"}, + unique_id="test@example.com", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "test@example.com", "password": "test-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From cbb78886b0969e5d3ecade5b51283bb510fa1186 Mon Sep 17 00:00:00 2001 From: Olivier d'Otreppe Date: Wed, 12 Nov 2025 20:24:15 +0000 Subject: [PATCH 23/26] Added device classes and remove unnecessery icons --- homeassistant/components/hanna/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py index 6171068cc7c43..26a3a55efc75b 100644 --- a/homeassistant/components/hanna/sensor.py +++ b/homeassistant/components/hanna/sensor.py @@ -28,13 +28,13 @@ SENSOR_DESCRIPTIONS = [ SensorEntityDescription( key="ph", + translation_key="ph_value", device_class=SensorDeviceClass.PH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="orp", translation_key="chlorine_orp_value", - icon="mdi:flask", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, state_class=SensorStateClass.MEASUREMENT, @@ -42,7 +42,6 @@ SensorEntityDescription( key="temp", translation_key="water_temperature", - icon="mdi:thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -50,7 +49,6 @@ SensorEntityDescription( key="airTemp", translation_key="air_temperature", - icon="mdi:thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -59,6 +57,7 @@ key="acidBase", translation_key="ph_acid_base_flow_rate", icon="mdi:chemical-weapon", + device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.MILLILITERS, state_class=SensorStateClass.MEASUREMENT, ), @@ -66,6 +65,7 @@ key="cl", translation_key="chlorine_flow_rate", icon="mdi:chemical-weapon", + device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.MILLILITERS, state_class=SensorStateClass.MEASUREMENT, ), From 6eee8bd25e9afcb90284169b23388fbfe7ea8b32 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Thu, 20 Nov 2025 12:05:53 +0100 Subject: [PATCH 24/26] Fix --- homeassistant/components/hanna/__init__.py | 7 +-- homeassistant/components/hanna/config_flow.py | 5 +-- homeassistant/components/hanna/const.py | 4 -- homeassistant/components/hanna/coordinator.py | 2 +- homeassistant/components/hanna/sensor.py | 11 +---- tests/components/hanna/conftest.py | 15 ++++--- tests/components/hanna/test_config_flow.py | 43 +++++++------------ 7 files changed, 33 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/hanna/__init__.py b/homeassistant/components/hanna/__init__.py index 1bdb00f9335a9..4d32cfb394216 100644 --- a/homeassistant/components/hanna/__init__.py +++ b/homeassistant/components/hanna/__init__.py @@ -2,12 +2,13 @@ from __future__ import annotations +from typing import Any + from hanna_cloud import HannaCloudClient -from homeassistant.const import Platform +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from .const import CONF_EMAIL, CONF_PASSWORD from .coordinator import HannaConfigEntry, HannaDataCoordinator PLATFORMS = [Platform.SENSOR] @@ -17,7 +18,7 @@ def _authenticate_and_get_devices( api_client: HannaCloudClient, email: str, password: str, -) -> list: +) -> list[dict[str, Any]]: """Authenticate and get devices in a single executor job.""" api_client.authenticate(email, password) return api_client.get_devices() diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py index 60aa62383c343..d1a54dc42cd30 100644 --- a/homeassistant/components/hanna/config_flow.py +++ b/homeassistant/components/hanna/config_flow.py @@ -33,6 +33,8 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: + await self.async_set_unique_id(user_input[CONF_EMAIL]) + self._abort_if_unique_id_configured() client = HannaCloudClient() try: await self.hass.async_add_executor_job( @@ -46,9 +48,6 @@ async def async_step_user( errors["base"] = "invalid_auth" if not errors: - identifier = user_input[CONF_EMAIL] - await self.async_set_unique_id(identifier) - self._abort_if_unique_id_configured() return self.async_create_entry( title=user_input[CONF_EMAIL], data=user_input, diff --git a/homeassistant/components/hanna/const.py b/homeassistant/components/hanna/const.py index bd73dec522ce3..ed9b5a84831aa 100644 --- a/homeassistant/components/hanna/const.py +++ b/homeassistant/components/hanna/const.py @@ -1,7 +1,3 @@ """Constants for the Hanna integration.""" DOMAIN = "hanna" - -# Config entry data keys -CONF_EMAIL = "email" -CONF_PASSWORD = "password" diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py index 3caa759f13140..c6915610d3f1a 100644 --- a/homeassistant/components/hanna/coordinator.py +++ b/homeassistant/components/hanna/coordinator.py @@ -34,12 +34,12 @@ def __init__( ) -> None: """Initialize the Hanna data coordinator.""" self.api_client = api_client - self.readings = None self.device_data = device super().__init__( hass, _LOGGER, name=f"{DOMAIN}_{self.device_identifier}", + config_entry=config_entry, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py index 26a3a55efc75b..6845f1a7c1038 100644 --- a/homeassistant/components/hanna/sensor.py +++ b/homeassistant/components/hanna/sensor.py @@ -98,16 +98,9 @@ def __init__( """Initialize a Hanna sensor.""" super().__init__(coordinator) self._attr_unique_id = f"{coordinator.device_identifier}_{description.key}" - self._attr_has_entity_name = True - self._attr_should_poll = False - self._attr_translation_key = description.translation_key - self._attr_icon = description.icon - self._attr_state_class = description.state_class - self._attr_device_class = description.device_class - self._attr_native_unit_of_measurement = description.native_unit_of_measurement - self.description = description + self.entity_description = description @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.coordinator.get_parameter_value(self.description.key) + return self.coordinator.get_parameter_value(self.entity_description.key) diff --git a/tests/components/hanna/conftest.py b/tests/components/hanna/conftest.py index a8e8641f55611..4585325ba8bd2 100644 --- a/tests/components/hanna/conftest.py +++ b/tests/components/hanna/conftest.py @@ -1,6 +1,6 @@ """Fixtures for Hanna Instruments integration tests.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -20,13 +20,13 @@ def mock_setup_entry(): @pytest.fixture def mock_hanna_client(): """Mock HannaCloudClient.""" - with patch( - "homeassistant.components.hanna.config_flow.HannaCloudClient" - ) as mock_client: + with ( + patch( + "homeassistant.components.hanna.config_flow.HannaCloudClient", autospec=True + ) as mock_client, + patch("homeassistant.components.hanna.HannaCloudClient", new=mock_client), + ): client = mock_client.return_value - client.authenticate = ( - MagicMock() - ) # Use MagicMock since it's called synchronously yield client @@ -37,4 +37,5 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, data={CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"}, title="test@example.com", + unique_id="test@example.com", ) diff --git a/tests/components/hanna/test_config_flow.py b/tests/components/hanna/test_config_flow.py index 978fcceceeb29..ed3c682b49e3d 100644 --- a/tests/components/hanna/test_config_flow.py +++ b/tests/components/hanna/test_config_flow.py @@ -8,21 +8,13 @@ from homeassistant.components.hanna.const import DOMAIN from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -def _create_exception(exception_type: type[Exception], message: str) -> Exception: - """Create an exception with proper setup for AuthenticationError.""" - exception = exception_type(message) - if isinstance(exception, AuthenticationError): - exception.response = MagicMock() - exception.response.status_code = 401 - return exception - - async def test_full_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -39,31 +31,32 @@ async def test_full_flow( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "email": "test@example.com", - "password": "test-password", + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test@example.com" assert result["data"] == { - "email": "test@example.com", - "password": "test-password", + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", } + assert result["result"].unique_id == "test@example.com" @pytest.mark.parametrize( ("exception", "expected_error"), [ ( - _create_exception(AuthenticationError, "Authentication failed"), + AuthenticationError("Authentication failed"), "invalid_auth", ), ( - _create_exception(Timeout, "Connection timeout"), + Timeout("Connection timeout"), "cannot_connect", ), ( - _create_exception(RequestsConnectionError, "Connection failed"), + RequestsConnectionError("Connection failed"), "cannot_connect", ), ], @@ -87,7 +80,7 @@ async def test_error_scenarios( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"email": "test@example.com", "password": "test-password"}, + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -97,13 +90,13 @@ async def test_error_scenarios( mock_hanna_client.authenticate.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"email": "test@example.com", "password": "test-password"}, + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test@example.com" assert result["data"] == { - "email": "test@example.com", - "password": "test-password", + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", } @@ -111,14 +104,10 @@ async def test_duplicate_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_hanna_client: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test that duplicate entries are aborted.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={"email": "test@example.com", "password": "test-password"}, - unique_id="test@example.com", - ) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -129,7 +118,7 @@ async def test_duplicate_entry( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"email": "test@example.com", "password": "test-password"}, + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" From 04320d8ef384d6c3d56284536964d69b1465e471 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Thu, 20 Nov 2025 12:31:08 +0100 Subject: [PATCH 25/26] Fix --- homeassistant/components/hanna/strings.json | 34 ++++++++++----------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/hanna/strings.json b/homeassistant/components/hanna/strings.json index baa5ba928d8a4..b37db29cef8cf 100644 --- a/homeassistant/components/hanna/strings.json +++ b/homeassistant/components/hanna/strings.json @@ -1,5 +1,13 @@ { "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, "step": { "user": { "data": { @@ -13,35 +21,27 @@ "description": "Enter your Hanna Cloud credentials", "title": "Hanna Cloud" } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "entity": { "sensor": { - "ph": { - "name": "pH" + "air_temperature": { + "name": "Air temperature" + }, + "chlorine_flow_rate": { + "name": "Chlorine flow rate" }, "chlorine_orp_value": { "name": "Chlorine ORP value" }, - "water_temperature": { - "name": "Water temperature" - }, - "air_temperature": { - "name": "Air temperature" + "ph": { + "name": "pH" }, "ph_acid_base_flow_rate": { "name": "pH Acid/Base flow rate" }, - "chlorine_flow_rate": { - "name": "Chlorine flow rate" + "water_temperature": { + "name": "Water temperature" } } } From 71e85193f8c7364d7de51150144d1ff070b13e06 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Thu, 20 Nov 2025 12:31:41 +0100 Subject: [PATCH 26/26] Fix --- homeassistant/components/hanna/strings.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hanna/strings.json b/homeassistant/components/hanna/strings.json index b37db29cef8cf..c94a284bde934 100644 --- a/homeassistant/components/hanna/strings.json +++ b/homeassistant/components/hanna/strings.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -18,8 +18,7 @@ "email": "Email address for your Hanna Cloud account", "password": "Password for your Hanna Cloud account" }, - "description": "Enter your Hanna Cloud credentials", - "title": "Hanna Cloud" + "description": "Enter your Hanna Cloud credentials" } } }, @@ -34,9 +33,6 @@ "chlorine_orp_value": { "name": "Chlorine ORP value" }, - "ph": { - "name": "pH" - }, "ph_acid_base_flow_rate": { "name": "pH Acid/Base flow rate" },