From f8267b13d7671860c2a2b9144c80cc2aec5fc914 Mon Sep 17 00:00:00 2001 From: Alena Bugrova <54861210+LoSk-p@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:57:51 +0300 Subject: [PATCH] Add Altruist integration to Core (#146158) * add altruist integration and tests * requested fixes + remove some deprecated sensors * add tests for unknown sensor and device attribute in config_flow * use CONF_ in data_schema * suggested fixes * remove test_setup_entry_success * create ZeroconfServiceInfo in tests * use CONF_IP_ADDRESS in tests * add unique id assert * add integration to strict-typing, set unavailable if no sensor key in data, change device name * use add_suggested_values_to_schema, mmHg for pressure * update snapshots and config entry name in tests * remove changes in devcontainer config * fixture for create client error, typing in tests, remove "Altruist" from device name * change native_value_fn return type * change sensor.py docstring * remove device id from entry data, fix docstrings * remove checks for client and device attributes * use less variables in tests * change creating AltruistSensor, remove device from arguments * Update homeassistant/components/altruist/sensor.py * Update homeassistant/components/altruist/quality_scale.yaml * Update homeassistant/components/altruist/quality_scale.yaml * Update quality_scale.yaml * hassfest run * suggested fixes * set suggested_unit_of_measurement for pressure * use mock_config_entry, update snapshots * abort if cant create client on zeroconf step * move sensor names in translatin placeholders --------- Co-authored-by: Josef Zweck --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/altruist/__init__.py | 27 + .../components/altruist/config_flow.py | 107 ++++ homeassistant/components/altruist/const.py | 5 + .../components/altruist/coordinator.py | 64 +++ homeassistant/components/altruist/icons.json | 15 + .../components/altruist/manifest.json | 12 + .../components/altruist/quality_scale.yaml | 83 +++ homeassistant/components/altruist/sensor.py | 249 +++++++++ .../components/altruist/strings.json | 51 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/altruist/__init__.py | 13 + tests/components/altruist/conftest.py | 82 +++ .../altruist/fixtures/real_data.json | 38 ++ .../altruist/fixtures/sensor_names.json | 11 + .../altruist/snapshots/test_sensor.ambr | 507 ++++++++++++++++++ tests/components/altruist/test_config_flow.py | 169 ++++++ tests/components/altruist/test_init.py | 53 ++ tests/components/altruist/test_sensor.py | 55 ++ 25 files changed, 1572 insertions(+) create mode 100644 homeassistant/components/altruist/__init__.py create mode 100644 homeassistant/components/altruist/config_flow.py create mode 100644 homeassistant/components/altruist/const.py create mode 100644 homeassistant/components/altruist/coordinator.py create mode 100644 homeassistant/components/altruist/icons.json create mode 100644 homeassistant/components/altruist/manifest.json create mode 100644 homeassistant/components/altruist/quality_scale.yaml create mode 100644 homeassistant/components/altruist/sensor.py create mode 100644 homeassistant/components/altruist/strings.json create mode 100644 tests/components/altruist/__init__.py create mode 100644 tests/components/altruist/conftest.py create mode 100644 tests/components/altruist/fixtures/real_data.json create mode 100644 tests/components/altruist/fixtures/sensor_names.json create mode 100644 tests/components/altruist/snapshots/test_sensor.ambr create mode 100644 tests/components/altruist/test_config_flow.py create mode 100644 tests/components/altruist/test_init.py create mode 100644 tests/components/altruist/test_sensor.py diff --git a/.strict-typing b/.strict-typing index b34cbfa5fca..68d67ae85b2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -67,6 +67,7 @@ homeassistant.components.alert.* homeassistant.components.alexa.* homeassistant.components.alexa_devices.* homeassistant.components.alpha_vantage.* +homeassistant.components.altruist.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* homeassistant.components.ambient_network.* diff --git a/CODEOWNERS b/CODEOWNERS index da247c06cb8..9f312c77b1e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -93,6 +93,8 @@ build.json @home-assistant/supervisor /tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh /homeassistant/components/alexa_devices/ @chemelli74 /tests/components/alexa_devices/ @chemelli74 +/homeassistant/components/altruist/ @airalab @LoSk-p +/tests/components/altruist/ @airalab @LoSk-p /homeassistant/components/amazon_polly/ @jschlyter /homeassistant/components/amberelectric/ @madpilot /tests/components/amberelectric/ @madpilot diff --git a/homeassistant/components/altruist/__init__.py b/homeassistant/components/altruist/__init__.py new file mode 100644 index 00000000000..6040b347bb5 --- /dev/null +++ b/homeassistant/components/altruist/__init__.py @@ -0,0 +1,27 @@ +"""The Altruist integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import AltruistConfigEntry, AltruistDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool: + """Set up Altruist from a config entry.""" + + coordinator = AltruistDataUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/altruist/config_flow.py b/homeassistant/components/altruist/config_flow.py new file mode 100644 index 00000000000..ec3c8f9d8f9 --- /dev/null +++ b/homeassistant/components/altruist/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for the Altruist integration.""" + +import logging +from typing import Any + +from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import CONF_HOST, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AltruistConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Altruist.""" + + device: AltruistDeviceModel + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + ip_address = "" + if user_input is not None: + ip_address = user_input[CONF_HOST] + try: + client = await AltruistClient.from_ip_address( + async_get_clientsession(self.hass), ip_address + ) + except AltruistError: + errors["base"] = "no_device_found" + else: + self.device = client.device + await self.async_set_unique_id( + client.device_id, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self.device.id, + data={ + CONF_HOST: ip_address, + }, + ) + + data_schema = self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_HOST): str}), + {CONF_HOST: ip_address}, + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + description_placeholders={ + "ip_address": ip_address, + }, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Zeroconf discovery: %s", discovery_info) + try: + client = await AltruistClient.from_ip_address( + async_get_clientsession(self.hass), str(discovery_info.ip_address) + ) + except AltruistError: + return self.async_abort(reason="no_device_found") + + self.device = client.device + _LOGGER.debug("Zeroconf device: %s", client.device) + await self.async_set_unique_id(client.device_id) + self._abort_if_unique_id_configured() + self.context.update( + { + "title_placeholders": { + "name": self.device.id, + } + } + ) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.device.id, + data={ + CONF_HOST: self.device.ip_address, + }, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "model": self.device.id, + }, + ) diff --git a/homeassistant/components/altruist/const.py b/homeassistant/components/altruist/const.py new file mode 100644 index 00000000000..93cbbd2c535 --- /dev/null +++ b/homeassistant/components/altruist/const.py @@ -0,0 +1,5 @@ +"""Constants for the Altruist integration.""" + +DOMAIN = "altruist" + +CONF_HOST = "host" diff --git a/homeassistant/components/altruist/coordinator.py b/homeassistant/components/altruist/coordinator.py new file mode 100644 index 00000000000..0a537e62af6 --- /dev/null +++ b/homeassistant/components/altruist/coordinator.py @@ -0,0 +1,64 @@ +"""Coordinator module for Altruist integration in Home Assistant. + +This module defines the AltruistDataUpdateCoordinator class, which manages +data updates for Altruist sensors using the AltruistClient. +""" + +from datetime import timedelta +import logging + +from altruistclient import AltruistClient, AltruistError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_HOST + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=15) + +type AltruistConfigEntry = ConfigEntry[AltruistDataUpdateCoordinator] + + +class AltruistDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Coordinates data updates for Altruist sensors.""" + + client: AltruistClient + + def __init__( + self, + hass: HomeAssistant, + config_entry: AltruistConfigEntry, + ) -> None: + """Initialize the data update coordinator for Altruist sensors.""" + device_id = config_entry.unique_id + super().__init__( + hass, + logger=_LOGGER, + config_entry=config_entry, + name=f"Altruist {device_id}", + update_interval=UPDATE_INTERVAL, + ) + self._ip_address = config_entry.data[CONF_HOST] + + async def _async_setup(self) -> None: + try: + self.client = await AltruistClient.from_ip_address( + async_get_clientsession(self.hass), self._ip_address + ) + await self.client.fetch_data() + except AltruistError as e: + raise ConfigEntryNotReady("Error in Altruist setup") from e + + async def _async_update_data(self) -> dict[str, str]: + try: + fetched_data = await self.client.fetch_data() + except AltruistError as ex: + raise UpdateFailed( + f"The Altruist {self.client.device_id} is unavailable: {ex}" + ) from ex + return {item["value_type"]: item["value"] for item in fetched_data} diff --git a/homeassistant/components/altruist/icons.json b/homeassistant/components/altruist/icons.json new file mode 100644 index 00000000000..9c012b87b6d --- /dev/null +++ b/homeassistant/components/altruist/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "pm_10": { + "default": "mdi:thought-bubble" + }, + "pm_25": { + "default": "mdi:thought-bubble-outline" + }, + "radiation": { + "default": "mdi:radioactive" + } + } + } +} diff --git a/homeassistant/components/altruist/manifest.json b/homeassistant/components/altruist/manifest.json new file mode 100644 index 00000000000..534830a9b70 --- /dev/null +++ b/homeassistant/components/altruist/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "altruist", + "name": "Altruist", + "codeowners": ["@airalab", "@LoSk-p"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/altruist", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["altruistclient==0.1.1"], + "zeroconf": ["_altruist._tcp.local."] +} diff --git a/homeassistant/components/altruist/quality_scale.yaml b/homeassistant/components/altruist/quality_scale.yaml new file mode 100644 index 00000000000..4566ac5f6df --- /dev/null +++ b/homeassistant/components/altruist/quality_scale.yaml @@ -0,0 +1,83 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + 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: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: done + 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: + status: exempt + comment: | + Device type integration + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No known use cases for repair issues or flows, yet + stale-devices: + status: exempt + comment: | + Device type integration + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/altruist/sensor.py b/homeassistant/components/altruist/sensor.py new file mode 100644 index 00000000000..f02c442e5cd --- /dev/null +++ b/homeassistant/components/altruist/sensor.py @@ -0,0 +1,249 @@ +"""Defines the Altruist sensor platform.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfPressure, + UnitOfSoundPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AltruistConfigEntry +from .coordinator import AltruistDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class AltruistSensorEntityDescription(SensorEntityDescription): + """Class to describe a Sensor entity.""" + + native_value_fn: Callable[[str], float] = float + state_class = SensorStateClass.MEASUREMENT + + +SENSOR_DESCRIPTIONS = [ + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key="BME280_humidity", + translation_key="humidity", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BME280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PRESSURE, + key="BME280_pressure", + translation_key="pressure", + native_unit_of_measurement=UnitOfPressure.PA, + suggested_unit_of_measurement=UnitOfPressure.MMHG, + suggested_display_precision=0, + translation_placeholders={"sensor_name": "BME280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="BME280_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BME280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PRESSURE, + key="BMP_pressure", + translation_key="pressure", + native_unit_of_measurement=UnitOfPressure.PA, + suggested_unit_of_measurement=UnitOfPressure.MMHG, + suggested_display_precision=0, + translation_placeholders={"sensor_name": "BMP"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="BMP_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BMP"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="BMP280_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BMP280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PRESSURE, + key="BMP280_pressure", + translation_key="pressure", + native_unit_of_measurement=UnitOfPressure.PA, + suggested_unit_of_measurement=UnitOfPressure.MMHG, + suggested_display_precision=0, + translation_placeholders={"sensor_name": "BMP280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key="HTU21D_humidity", + translation_key="humidity", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "HTU21D"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="HTU21D_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "HTU21D"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PM10, + translation_key="pm_10", + key="SDS_P1", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PM25, + translation_key="pm_25", + key="SDS_P2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key="SHT3X_humidity", + translation_key="humidity", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "SHT3X"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="SHT3X_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "SHT3X"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + key="signal", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.SOUND_PRESSURE, + key="PCBA_noiseMax", + translation_key="noise_max", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + suggested_display_precision=0, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.SOUND_PRESSURE, + key="PCBA_noiseAvg", + translation_key="noise_avg", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + suggested_display_precision=0, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + translation_key="co2", + key="CCS_CO2", + suggested_display_precision=2, + translation_placeholders={"sensor_name": "CCS"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + key="CCS_TVOC", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + key="GC", + native_unit_of_measurement="μR/h", + translation_key="radiation", + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.CO2, + translation_key="co2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + key="SCD4x_co2", + suggested_display_precision=2, + translation_placeholders={"sensor_name": "SCD4x"}, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AltruistConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add sensors for passed config_entry in HA.""" + coordinator = config_entry.runtime_data + async_add_entities( + AltruistSensor(coordinator, sensor_description) + for sensor_description in SENSOR_DESCRIPTIONS + if sensor_description.key in coordinator.data + ) + + +class AltruistSensor(CoordinatorEntity[AltruistDataUpdateCoordinator], SensorEntity): + """Implementation of a Altruist sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AltruistDataUpdateCoordinator, + description: AltruistSensorEntityDescription, + ) -> None: + """Initialize the Altruist sensor.""" + super().__init__(coordinator) + self._device = coordinator.client.device + self.entity_description: AltruistSensorEntityDescription = description + self._attr_unique_id = f"{self._device.id}-{description.key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._device.id)}, + manufacturer="Robonomics", + model="Altruist", + sw_version=self._device.fw_version, + configuration_url=f"http://{self._device.ip_address}", + serial_number=self._device.id, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available and self.entity_description.key in self.coordinator.data + ) + + @property + def native_value(self) -> float | int: + """Return the native value of the sensor.""" + string_value = self.coordinator.data[self.entity_description.key] + return self.entity_description.native_value_fn(string_value) diff --git a/homeassistant/components/altruist/strings.json b/homeassistant/components/altruist/strings.json new file mode 100644 index 00000000000..a466e1e3c9d --- /dev/null +++ b/homeassistant/components/altruist/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "discovery_confirm": { + "description": "Do you want to start setup {model}?" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Altruist IP address or hostname in the local network" + }, + "description": "Fill in Altruist IP address or hostname in your local network" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "no_device_found": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "sensor": { + "humidity": { + "name": "{sensor_name} humidity" + }, + "pressure": { + "name": "{sensor_name} pressure" + }, + "temperature": { + "name": "{sensor_name} temperature" + }, + "noise_max": { + "name": "Maximum noise" + }, + "noise_avg": { + "name": "Average noise" + }, + "co2": { + "name": "{sensor_name} CO2" + }, + "radiation": { + "name": "Radiation level" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e164bc09929..b9dfefd3327 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -48,6 +48,7 @@ FLOWS = { "airzone_cloud", "alarmdecoder", "alexa_devices", + "altruist", "amberelectric", "ambient_network", "ambient_station", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1202d7d51ec..b3918ac8ded 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -204,6 +204,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "altruist": { + "name": "Altruist", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "amazon": { "name": "Amazon", "integrations": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e675a0bb237..21abaa2a579 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -342,6 +342,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_altruist._tcp.local.": [ + { + "domain": "altruist", + }, + ], "_amzn-alexa._tcp.local.": [ { "domain": "roomba", diff --git a/mypy.ini b/mypy.ini index 1fdab75663e..72e52b67959 100644 --- a/mypy.ini +++ b/mypy.ini @@ -425,6 +425,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.altruist.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.amazon_polly.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 0286243cb08..8d8817102cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -461,6 +461,9 @@ airtouch5py==0.3.0 # homeassistant.components.alpha_vantage alpha-vantage==2.3.1 +# homeassistant.components.altruist +altruistclient==0.1.1 + # homeassistant.components.amberelectric amberelectric==2.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26f91948e08..b53a6779b4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -440,6 +440,9 @@ airtouch4pyapi==1.0.5 # homeassistant.components.airtouch5 airtouch5py==0.3.0 +# homeassistant.components.altruist +altruistclient==0.1.1 + # homeassistant.components.amberelectric amberelectric==2.0.12 diff --git a/tests/components/altruist/__init__.py b/tests/components/altruist/__init__.py new file mode 100644 index 00000000000..bdbd8c0532a --- /dev/null +++ b/tests/components/altruist/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Altruist integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/altruist/conftest.py b/tests/components/altruist/conftest.py new file mode 100644 index 00000000000..3a7fcd1afe7 --- /dev/null +++ b/tests/components/altruist/conftest.py @@ -0,0 +1,82 @@ +"""Altruist tests configuration.""" + +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, Mock, patch + +from altruistclient import AltruistDeviceModel, AltruistError +import pytest + +from homeassistant.components.altruist.const import CONF_HOST, DOMAIN + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.altruist.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id="5366960e8b18", + title="5366960e8b18", + ) + + +@pytest.fixture +def mock_altruist_device() -> Mock: + """Return a mock AltruistDeviceModel.""" + device = Mock(spec=AltruistDeviceModel) + device.id = "5366960e8b18" + device.name = "Altruist Sensor" + device.ip_address = "192.168.1.100" + device.fw_version = "R_2025-03" + return device + + +@pytest.fixture +def mock_altruist_client(mock_altruist_device: Mock) -> Generator[AsyncMock]: + """Return a mock AltruistClient.""" + with ( + patch( + "homeassistant.components.altruist.coordinator.AltruistClient", + autospec=True, + ) as mock_client_class, + patch( + "homeassistant.components.altruist.config_flow.AltruistClient", + new=mock_client_class, + ), + ): + mock_instance = AsyncMock() + mock_instance.device = mock_altruist_device + mock_instance.device_id = mock_altruist_device.id + mock_instance.sensor_names = json.loads( + load_fixture("sensor_names.json", DOMAIN) + ) + mock_instance.fetch_data.return_value = json.loads( + load_fixture("real_data.json", DOMAIN) + ) + + mock_client_class.from_ip_address = AsyncMock(return_value=mock_instance) + + yield mock_instance + + +@pytest.fixture +def mock_altruist_client_fails_once(mock_altruist_client: AsyncMock) -> Generator[None]: + """Patch AltruistClient to fail once and then succeed.""" + with patch( + "homeassistant.components.altruist.config_flow.AltruistClient.from_ip_address", + side_effect=[AltruistError("Connection failed"), mock_altruist_client], + ): + yield diff --git a/tests/components/altruist/fixtures/real_data.json b/tests/components/altruist/fixtures/real_data.json new file mode 100644 index 00000000000..86700f50b4f --- /dev/null +++ b/tests/components/altruist/fixtures/real_data.json @@ -0,0 +1,38 @@ +[ + { + "value_type": "signal", + "value": "-48" + }, + { + "value_type": "SDS_P1", + "value": "0.1" + }, + { + "value_type": "SDS_P2", + "value": "0.23" + }, + { + "value_type": "BME280_humidity", + "value": "54.94141" + }, + { + "value_type": "BME280_temperature", + "value": "22.95313" + }, + { + "value_type": "BME280_pressure", + "value": "99978.16" + }, + { + "value_type": "PCBA_noiseMax", + "value": "60" + }, + { + "value_type": "PCBA_noiseAvg", + "value": "51" + }, + { + "value_type": "GC", + "value": "15.2" + } +] diff --git a/tests/components/altruist/fixtures/sensor_names.json b/tests/components/altruist/fixtures/sensor_names.json new file mode 100644 index 00000000000..41aa997326c --- /dev/null +++ b/tests/components/altruist/fixtures/sensor_names.json @@ -0,0 +1,11 @@ +[ + "signal", + "SDS_P1", + "SDS_P2", + "BME280_humidity", + "BME280_temperature", + "BME280_pressure", + "PCBA_noiseMax", + "PCBA_noiseAvg", + "GC" +] diff --git a/tests/components/altruist/snapshots/test_sensor.ambr b/tests/components/altruist/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..ca74e75542f --- /dev/null +++ b/tests/components/altruist/snapshots/test_sensor.ambr @@ -0,0 +1,507 @@ +# serializer version: 1 +# name: test_all_entities[sensor.5366960e8b18_average_noise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_average_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average noise', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'noise_avg', + 'unique_id': '5366960e8b18-PCBA_noiseAvg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_average_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound_pressure', + 'friendly_name': '5366960e8b18 Average noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_average_noise', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.0', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_bme280_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'BME280 humidity', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '5366960e8b18-BME280_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': '5366960e8b18 BME280 humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_bme280_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54.94141', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_bme280_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'BME280 pressure', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pressure', + 'unique_id': '5366960e8b18-BME280_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': '5366960e8b18 BME280 pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_bme280_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '749.897762397492', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_bme280_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'BME280 temperature', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '5366960e8b18-BME280_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '5366960e8b18 BME280 temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_bme280_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.95313', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_maximum_noise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_maximum_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum noise', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'noise_max', + 'unique_id': '5366960e8b18-PCBA_noiseMax', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_maximum_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound_pressure', + 'friendly_name': '5366960e8b18 Maximum noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_maximum_noise', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm_10', + 'unique_id': '5366960e8b18-SDS_P1', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': '5366960e8b18 PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm_25', + 'unique_id': '5366960e8b18-SDS_P2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': '5366960e8b18 PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.23', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_radiation_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_radiation_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Radiation level', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radiation', + 'unique_id': '5366960e8b18-GC', + 'unit_of_measurement': 'μR/h', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_radiation_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '5366960e8b18 Radiation level', + 'state_class': , + 'unit_of_measurement': 'μR/h', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_radiation_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.2', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.5366960e8b18_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5366960e8b18-signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': '5366960e8b18 Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-48.0', + }) +# --- diff --git a/tests/components/altruist/test_config_flow.py b/tests/components/altruist/test_config_flow.py new file mode 100644 index 00000000000..3d04e893d62 --- /dev/null +++ b/tests/components/altruist/test_config_flow.py @@ -0,0 +1,169 @@ +"""Test the Altruist config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from homeassistant.components.altruist.const import CONF_HOST, DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from tests.common import MockConfigEntry + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], + hostname="altruist-purple.local.", + name="altruist-purple._altruist._tcp.local.", + port=80, + type="_altruist._tcp.local.", + properties={ + "PATH": "/config", + }, +) + + +async def test_form_user_step_success( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user step shows form and succeeds with valid input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "5366960e8b18" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + } + assert result["result"].unique_id == "5366960e8b18" + + +async def test_form_user_step_cannot_connect_then_recovers( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_altruist_client_fails_once: None, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle connection error and allow recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # First attempt triggers an error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_device_found"} + + # Second attempt recovers with a valid client + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "5366960e8b18" + assert result["result"].unique_id == "5366960e8b18" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + } + + +async def test_form_user_step_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "5366960e8b18" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + } + assert result["result"].unique_id == "5366960e8b18" + + +async def test_zeroconf_discovery_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_discovery_cant_create_client( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client_fails_once: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_device_found" diff --git a/tests/components/altruist/test_init.py b/tests/components/altruist/test_init.py new file mode 100644 index 00000000000..67d5b01acb6 --- /dev/null +++ b/tests/components/altruist/test_init.py @@ -0,0 +1,53 @@ +"""Test the Altruist integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry_client_creation_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client_fails_once: None, +) -> None: + """Test setup failure when client creation fails.""" + mock_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_fetch_data_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, +) -> None: + """Test setup failure when initial data fetch fails.""" + mock_config_entry.add_to_hass(hass) + mock_altruist_client.fetch_data.side_effect = Exception("Fetch failed") + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, +) -> None: + """Test unloading of config entry.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Now test unloading + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/altruist/test_sensor.py b/tests/components/altruist/test_sensor.py new file mode 100644 index 00000000000..1214adc488f --- /dev/null +++ b/tests/components/altruist/test_sensor.py @@ -0,0 +1,55 @@ +"""Tests for the Altruist integration sensor platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from altruistclient import AltruistError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_altruist_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.altruist.PLATFORMS", [Platform.SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_connection_error( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator error handling during update.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_altruist_client.fetch_data.side_effect = AltruistError() + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.5366960e8b18_bme280_temperature").state + == STATE_UNAVAILABLE + )