diff --git a/.strict-typing b/.strict-typing index ba072005a3415..34b9118a2f024 100644 --- a/.strict-typing +++ b/.strict-typing @@ -185,6 +185,7 @@ homeassistant.components.duco.* homeassistant.components.dunehd.* homeassistant.components.duotecno.* homeassistant.components.easyenergy.* +homeassistant.components.ecobulles.* homeassistant.components.ecovacs.* homeassistant.components.ecowitt.* homeassistant.components.efergy.* diff --git a/CODEOWNERS b/CODEOWNERS index 87a4c2155aac1..564cb6dde59dc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -445,6 +445,8 @@ CLAUDE.md @home-assistant/core /tests/components/earn_e_p1/ @Miggets7 /homeassistant/components/easyenergy/ @klaasnicolaas /tests/components/easyenergy/ @klaasnicolaas +/homeassistant/components/ecobulles/ @jul-fls +/tests/components/ecobulles/ @jul-fls /homeassistant/components/ecoforest/ @pjanuario /tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @w1ll1am23 diff --git a/homeassistant/components/ecobulles/__init__.py b/homeassistant/components/ecobulles/__init__.py new file mode 100755 index 0000000000000..f7e70e4424d4e --- /dev/null +++ b/homeassistant/components/ecobulles/__init__.py @@ -0,0 +1,59 @@ +"""The Ecobulles integration.""" + +from pyecobulles import EcobullesClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.util.dt import now as hass_now + +from .const import DOMAIN +from .coordinator import EcobullesCoordinator +from .device import mac_from_eco_ref, model_from_serial_number + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type EcobullesConfigEntry = ConfigEntry[EcobullesCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: EcobullesConfigEntry) -> bool: + """Set up Ecobulles from a config entry.""" + assert entry.unique_id is not None + eco_ref = entry.unique_id + boitier_name = entry.data.get("name") + num_serie = entry.data.get("num_serie") + firmware_version = entry.data.get("firmware_version") + + device_registry = dr.async_get(hass) + mac_address = mac_from_eco_ref(eco_ref) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, eco_ref)}, + name=boitier_name, + manufacturer="Ecobulles", + model=model_from_serial_number(num_serie), + sw_version=firmware_version, + serial_number=num_serie, + connections={(CONNECTION_NETWORK_MAC, mac_address)} if mac_address else set(), + ) + + coordinator = EcobullesCoordinator( + hass, + EcobullesClient(session=async_get_clientsession(hass), now_fn=hass_now), + 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: EcobullesConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ecobulles/config_flow.py b/homeassistant/components/ecobulles/config_flow.py new file mode 100755 index 0000000000000..a1c2376b91d30 --- /dev/null +++ b/homeassistant/components/ecobulles/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for Ecobulles integration.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from pyecobulles import EcobullesClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.dt import now as hass_now + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def validate_input( + hass: HomeAssistant, data: Mapping[str, Any] +) -> Mapping[str, Any]: + """Validate the user input allows us to connect.""" + client = EcobullesClient(session=async_get_clientsession(hass), now_fn=hass_now) + try: + auth_success, user_id, eco_ref, boitier_name = await client.authenticate( + data[CONF_EMAIL], data[CONF_PASSWORD] + ) + if not auth_success or eco_ref is None: + raise InvalidAuth + device_info_raw = await client.get_device_info(eco_ref) + except TimeoutError as err: + raise CannotConnect from err + except RuntimeError as err: + raise CannotConnect from err + + device_name = (boitier_name or "").strip() + box = (device_info_raw or {}).get("data", {}).get("boite", {}) + resolved_name = (box.get("name") or device_name or "").strip() + return { + "title": f"Ecobulles : {resolved_name}" if resolved_name else "Ecobulles", + "user_id": user_id, + "eco_ref": eco_ref, + "name": resolved_name, + "firmware_version": box.get("firm_ver"), + "num_serie": box.get("num_serie"), + } + + +class EcobullesConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ecobulles.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + entry_data = {**user_input, **info} + title = entry_data.pop("title") + await self.async_set_unique_id(info["eco_ref"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=title, data=entry_data) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(Exception): + """Error to indicate invalid auth.""" diff --git a/homeassistant/components/ecobulles/const.py b/homeassistant/components/ecobulles/const.py new file mode 100755 index 0000000000000..1994e000a564f --- /dev/null +++ b/homeassistant/components/ecobulles/const.py @@ -0,0 +1,3 @@ +"""Constants for the Ecobulles integration.""" + +DOMAIN = "ecobulles" diff --git a/homeassistant/components/ecobulles/coordinator.py b/homeassistant/components/ecobulles/coordinator.py new file mode 100644 index 0000000000000..b457a7c80096d --- /dev/null +++ b/homeassistant/components/ecobulles/coordinator.py @@ -0,0 +1,79 @@ +"""Data update coordinator for Ecobulles.""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import TYPE_CHECKING + +from pyecobulles import EcobullesClient + +from homeassistant.const import CONF_EMAIL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +if TYPE_CHECKING: + from . import EcobullesConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class EcobullesData: + """Runtime data fetched from Ecobulles.""" + + water_liters: int + co2_injection_time_seconds: float + last_updated: str | None + + +class EcobullesCoordinator(DataUpdateCoordinator[EcobullesData]): + """Fetch Ecobulles cloud data.""" + + def __init__( + self, + hass: HomeAssistant, + api: EcobullesClient, + config_entry: EcobullesConfigEntry, + ) -> None: + """Initialize the coordinator.""" + assert config_entry.unique_id is not None + self.api = api + self.eco_ref = config_entry.unique_id + super().__init__( + hass, + _LOGGER, + name=f"Ecobulles {config_entry.data[CONF_EMAIL]}", + update_interval=timedelta(seconds=120), + config_entry=config_entry, + ) + + async def _async_update_data(self) -> EcobullesData: + """Fetch Ecobulles data.""" + try: + async with asyncio.timeout(15): + usage = await self.api.get_total_water_and_co2_usage(self.eco_ref) + except TimeoutError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except RuntimeError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(err)}, + ) from err + + if usage is None or "total_eau" not in usage or "total_gas" not in usage: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_payload_incomplete", + ) + + return EcobullesData( + water_liters=usage["total_eau"], + co2_injection_time_seconds=round(int(usage["total_gas"]) / 1000, 3), + last_updated=usage.get("last_updated"), + ) diff --git a/homeassistant/components/ecobulles/device.py b/homeassistant/components/ecobulles/device.py new file mode 100755 index 0000000000000..b4d1a54fe8df8 --- /dev/null +++ b/homeassistant/components/ecobulles/device.py @@ -0,0 +1,32 @@ +"""Device metadata helpers for Ecobulles.""" + +from string import hexdigits + +from homeassistant.helpers.device_registry import format_mac + + +def model_from_serial_number(serial_number: str | None) -> str: + """Infer the Ecobulles model from the serial number prefix. + + The API does not currently expose an explicit product model. Based on + observed serials, `X...` appears to identify Expert devices and `E...` + appears to identify Équilibre devices. Unknown prefixes deliberately fall + back to the generic brand name. + """ + if not serial_number: + return "Ecobulles" + + prefix = serial_number.strip().upper()[:1] + if prefix == "X": + return "Ecobulles Expert" + if prefix == "E": + return "Ecobulles Équilibre" + return "Ecobulles" + + +def mac_from_eco_ref(eco_ref: str) -> str | None: + """Return a canonical MAC address when the Ecobulles reference is one.""" + raw = eco_ref.replace(":", "").replace("-", "").strip() + if len(raw) != 12 or any(char not in hexdigits for char in raw): + return None + return format_mac(raw) diff --git a/homeassistant/components/ecobulles/icons.json b/homeassistant/components/ecobulles/icons.json new file mode 100755 index 0000000000000..134d82dea9a57 --- /dev/null +++ b/homeassistant/components/ecobulles/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "co2_injection_time": { + "default": "mdi:molecule-co2" + } + } + } +} diff --git a/homeassistant/components/ecobulles/manifest.json b/homeassistant/components/ecobulles/manifest.json new file mode 100755 index 0000000000000..4c7aef01fc6b4 --- /dev/null +++ b/homeassistant/components/ecobulles/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ecobulles", + "name": "Ecobulles", + "codeowners": ["@jul-fls"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ecobulles", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["pyecobulles==0.1.1"] +} diff --git a/homeassistant/components/ecobulles/quality_scale.yaml b/homeassistant/components/ecobulles/quality_scale.yaml new file mode 100755 index 0000000000000..aaf7798e7816a --- /dev/null +++ b/homeassistant/components/ecobulles/quality_scale.yaml @@ -0,0 +1,26 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities do not subscribe to external events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done diff --git a/homeassistant/components/ecobulles/sensor.py b/homeassistant/components/ecobulles/sensor.py new file mode 100755 index 0000000000000..e65e79c114778 --- /dev/null +++ b/homeassistant/components/ecobulles/sensor.py @@ -0,0 +1,92 @@ +"""Sensor platform for Ecobulles.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTime, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import EcobullesConfigEntry +from .const import DOMAIN +from .coordinator import EcobullesCoordinator, EcobullesData + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class EcobullesSensorDescription(SensorEntityDescription): + """Describe an Ecobulles sensor.""" + + value_fn: Callable[[EcobullesData], StateType] + + +SENSORS: tuple[EcobullesSensorDescription, ...] = ( + EcobullesSensorDescription( + key="water_usage", + translation_key="water_usage", + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.water_liters, + ), + EcobullesSensorDescription( + key="co2_injection_time", + translation_key="co2_injection_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.co2_injection_time_seconds, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EcobullesConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Ecobulles sensors from a config entry.""" + assert entry.unique_id is not None + async_add_entities( + EcobullesSensor(entry.runtime_data, entry.unique_id, description) + for description in SENSORS + ) + + +class EcobullesSensor(CoordinatorEntity[EcobullesCoordinator], SensorEntity): + """Ecobulles sensor backed by an entity description.""" + + _attr_has_entity_name = True + entity_description: EcobullesSensorDescription + + def __init__( + self, + coordinator: EcobullesCoordinator, + eco_ref: str, + description: EcobullesSensorDescription, + ) -> None: + """Initialize an Ecobulles sensor.""" + super().__init__(coordinator) + self.eco_ref = eco_ref + self.entity_description = description + self._attr_unique_id = f"{eco_ref}_{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return device registry metadata.""" + return DeviceInfo(identifiers={(DOMAIN, self.eco_ref)}) + + @property + def native_value(self) -> StateType: + """Return the current sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/ecobulles/strings.json b/homeassistant/components/ecobulles/strings.json new file mode 100755 index 0000000000000..c29d1cfc4c46c --- /dev/null +++ b/homeassistant/components/ecobulles/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "data_description": { + "email": "The email address used to sign in to the Ecobulles mobile app.", + "password": "The password used to sign in to the Ecobulles mobile app." + } + } + } + }, + "entity": { + "sensor": { + "co2_injection_time": { + "name": "CO2 injection time" + }, + "water_usage": { + "name": "Water usage" + } + } + }, + "exceptions": { + "api_payload_incomplete": { + "message": "Ecobulles returned incomplete usage data." + }, + "cannot_connect": { + "message": "Failed to connect to Ecobulles." + }, + "update_error": { + "message": "Unexpected error while updating Ecobulles: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7653c9c77cb18..f031291320e88 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -182,6 +182,7 @@ "earn_e_p1", "easyenergy", "ecobee", + "ecobulles", "ecoforest", "econet", "ecovacs", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c25fac49108ea..e5029e8d630b6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1630,6 +1630,12 @@ "iot_class": "cloud_polling", "single_config_entry": true }, + "ecobulles": { + "name": "Ecobulles", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ecoforest": { "name": "Ecoforest", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 6871bd7c88241..127809a62dbe7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1607,6 +1607,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ecobulles.*] +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.ecovacs.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 8830f3d001dc3..e6daabaeac75e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2116,6 +2116,9 @@ pydroplet==2.3.4 # homeassistant.components.ebox pyebox==1.1.4 +# homeassistant.components.ecobulles +pyecobulles==0.1.1 + # homeassistant.components.ecoforest pyecoforest==0.4.0 diff --git a/tests/components/ecobulles/__init__.py b/tests/components/ecobulles/__init__.py new file mode 100755 index 0000000000000..50a974027097d --- /dev/null +++ b/tests/components/ecobulles/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ecobulles integration.""" diff --git a/tests/components/ecobulles/conftest.py b/tests/components/ecobulles/conftest.py new file mode 100755 index 0000000000000..67201f1f52672 --- /dev/null +++ b/tests/components/ecobulles/conftest.py @@ -0,0 +1,25 @@ +"""Pytest fixtures for Ecobulles tests.""" + +import pytest + +from homeassistant.components.ecobulles.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "user@example.com", + CONF_PASSWORD: "secret", + "eco_ref": "test-eco-ref", + "name": "Test box", + "num_serie": "XC240007", + "firmware_version": "1.0", + }, + unique_id="test-eco-ref", + ) diff --git a/tests/components/ecobulles/test_config_flow.py b/tests/components/ecobulles/test_config_flow.py new file mode 100755 index 0000000000000..baa4e58632207 --- /dev/null +++ b/tests/components/ecobulles/test_config_flow.py @@ -0,0 +1,238 @@ +"""Tests for the Ecobulles config flow.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.ecobulles.config_flow import ( + CannotConnect, + InvalidAuth, + validate_input, +) +from homeassistant.components.ecobulles.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.usefixtures("enable_custom_integrations"), +] + + +USER_INPUT = { + CONF_EMAIL: "user@example.com", + CONF_PASSWORD: "secret", +} + +FLOW_INFO = { + "title": "Ecobulles : Test box", + "user_id": "user-id", + "eco_ref": "test-eco-ref", + "name": "Test box", + "firmware_version": "1.0", + "num_serie": "XC240007", +} + +DEVICE_INFO = { + "data": { + "boite": { + "name": "Test box", + "firm_ver": "1.0", + "num_serie": "XC240007", + } + } +} + + +async def test_user_flow_creates_entry(hass: HomeAssistant) -> None: + """Successful setup creates a config entry.""" + with ( + patch( + "homeassistant.components.ecobulles.config_flow.validate_input", + AsyncMock(return_value=FLOW_INFO), + ), + patch( + "homeassistant.components.ecobulles.async_setup_entry", + AsyncMock(return_value=True), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == FLOW_INFO["title"] + assert result["data"][CONF_EMAIL] == USER_INPUT[CONF_EMAIL] + assert result["data"][CONF_PASSWORD] == USER_INPUT[CONF_PASSWORD] + assert result["data"]["eco_ref"] == "test-eco-ref" + assert result["data"]["user_id"] == "user-id" + assert result["data"]["name"] == "Test box" + assert result["data"]["firmware_version"] == "1.0" + assert result["data"]["num_serie"] == "XC240007" + assert result["result"].unique_id == "test-eco-ref" + + +async def test_user_flow_handles_connection_error(hass: HomeAssistant) -> None: + """Connection errors are surfaced on the form.""" + with patch( + "homeassistant.components.ecobulles.config_flow.validate_input", + AsyncMock(side_effect=CannotConnect()), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=USER_INPUT, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_flow_handles_invalid_auth(hass: HomeAssistant) -> None: + """Invalid auth is surfaced on the form.""" + with patch( + "homeassistant.components.ecobulles.config_flow.validate_input", + AsyncMock(side_effect=InvalidAuth()), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=USER_INPUT, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_handles_unknown_error(hass: HomeAssistant) -> None: + """Unexpected validation errors are surfaced on the form.""" + with patch( + "homeassistant.components.ecobulles.config_flow.validate_input", + AsyncMock(side_effect=ValueError("boom")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=USER_INPUT, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "unknown"} + + +async def test_user_flow_rejects_existing_entry(hass: HomeAssistant) -> None: + """Adding an already-known device aborts the flow.""" + existing_entry = MockConfigEntry( + domain=DOMAIN, + data={**USER_INPUT, "eco_ref": "test-eco-ref"}, + unique_id="test-eco-ref", + ) + existing_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ecobulles.config_flow.validate_input", + AsyncMock(return_value=FLOW_INFO), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=USER_INPUT, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_user_flow_without_input_shows_form(hass: HomeAssistant) -> None: + """The first user step shows a form before credentials are submitted.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + +async def test_validate_input_normalizes_success(hass: HomeAssistant) -> None: + """validate_input returns normalized metadata after authentication.""" + with ( + patch( + "homeassistant.components.ecobulles.config_flow.EcobullesClient.authenticate", + AsyncMock(return_value=(True, "user-id", "eco-ref", "Box")), + ), + patch( + "homeassistant.components.ecobulles.config_flow.EcobullesClient.get_device_info", + AsyncMock(return_value=DEVICE_INFO), + ), + ): + assert await validate_input(hass, USER_INPUT) == { + "title": "Ecobulles : Test box", + "user_id": "user-id", + "eco_ref": "eco-ref", + "name": "Test box", + "firmware_version": "1.0", + "num_serie": "XC240007", + } + + +async def test_validate_input_uses_box_name_for_title_when_auth_name_missing( + hass: HomeAssistant, +) -> None: + """validate_input uses the resolved device name consistently.""" + with ( + patch( + "homeassistant.components.ecobulles.config_flow.EcobullesClient.authenticate", + AsyncMock(return_value=(True, "user-id", "eco-ref", " ")), + ), + patch( + "homeassistant.components.ecobulles.config_flow.EcobullesClient.get_device_info", + AsyncMock(return_value=DEVICE_INFO), + ), + ): + result = await validate_input(hass, USER_INPUT) + + assert result["title"] == "Ecobulles : Test box" + assert result["name"] == "Test box" + + +async def test_validate_input_maps_runtime_errors(hass: HomeAssistant) -> None: + """Low-level API runtime errors become cannot-connect errors.""" + with ( + patch( + "homeassistant.components.ecobulles.config_flow.EcobullesClient.authenticate", + AsyncMock(side_effect=RuntimeError("network")), + ), + pytest.raises(CannotConnect), + ): + await validate_input(hass, USER_INPUT) + + +async def test_validate_input_maps_timeout_errors(hass: HomeAssistant) -> None: + """Low-level API timeouts become cannot-connect errors.""" + with ( + patch( + "homeassistant.components.ecobulles.config_flow.EcobullesClient.authenticate", + AsyncMock(side_effect=TimeoutError), + ), + pytest.raises(CannotConnect), + ): + await validate_input(hass, USER_INPUT) + + +async def test_validate_input_rejects_invalid_auth(hass: HomeAssistant) -> None: + """Authentication failures become invalid-auth errors.""" + with ( + patch( + "homeassistant.components.ecobulles.config_flow.EcobullesClient.authenticate", + AsyncMock(return_value=(False, None, None, None)), + ), + pytest.raises(InvalidAuth), + ): + await validate_input(hass, USER_INPUT) diff --git a/tests/components/ecobulles/test_device.py b/tests/components/ecobulles/test_device.py new file mode 100755 index 0000000000000..b182e388ff760 --- /dev/null +++ b/tests/components/ecobulles/test_device.py @@ -0,0 +1,21 @@ +"""Tests for Ecobulles device metadata helpers.""" + +from homeassistant.components.ecobulles.device import ( + mac_from_eco_ref, + model_from_serial_number, +) + + +def test_model_from_serial_number() -> None: + """Infer known model names from observed serial prefixes.""" + assert model_from_serial_number("XC240007") == "Ecobulles Expert" + assert model_from_serial_number("E123456") == "Ecobulles Équilibre" + assert model_from_serial_number("Z123456") == "Ecobulles" + assert model_from_serial_number(None) == "Ecobulles" + + +def test_mac_from_eco_ref() -> None: + """Only MAC-shaped Ecobulles references become device-registry connections.""" + assert mac_from_eco_ref("44B7D095E9C6") == "44:b7:d0:95:e9:c6" + assert mac_from_eco_ref("44:B7:D0:95:E9:C6") == "44:b7:d0:95:e9:c6" + assert mac_from_eco_ref("not-a-mac") is None diff --git a/tests/components/ecobulles/test_sensor.py b/tests/components/ecobulles/test_sensor.py new file mode 100755 index 0000000000000..1c6275179dd3b --- /dev/null +++ b/tests/components/ecobulles/test_sensor.py @@ -0,0 +1,152 @@ +"""Focused unit tests for Ecobulles sensor internals.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.ecobulles.const import DOMAIN +from homeassistant.components.ecobulles.coordinator import ( + EcobullesCoordinator, + EcobullesData, +) +from homeassistant.components.ecobulles.sensor import ( + SENSORS, + EcobullesSensor, + async_setup_entry, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +pytestmark = pytest.mark.asyncio + + +def _usage(total_eau: int = 100, total_gas: int = 150_000) -> dict: + """Return a minimal usage payload.""" + return { + "total_eau": total_eau, + "total_gas": total_gas, + "last_updated": "2026-05-21T00:17:58", + } + + +def _coordinator( + hass: HomeAssistant, mock_config_entry, api=None +) -> EcobullesCoordinator: + """Build a coordinator with mocked API.""" + return EcobullesCoordinator( + hass, + api + or SimpleNamespace( + get_total_water_and_co2_usage=AsyncMock(return_value=_usage()), + ), + mock_config_entry, + ) + + +async def test_sensor_setup(hass: HomeAssistant, mock_config_entry) -> None: + """Sensor setup creates the Ecobulles sensors.""" + mock_config_entry.runtime_data = _coordinator(hass, mock_config_entry) + add_entities = MagicMock() + + # pylint: disable-next=home-assistant-tests-direct-platform-async-setup-entry + await async_setup_entry(hass, mock_config_entry, add_entities) + + unique_ids = {entity.unique_id for entity in add_entities.call_args.args[0]} + assert unique_ids == { + "test-eco-ref_water_usage", + "test-eco-ref_co2_injection_time", + } + + +async def test_coordinator_update_success( + hass: HomeAssistant, mock_config_entry +) -> None: + """Coordinator exposes usage as typed runtime data.""" + coordinator = _coordinator( + hass, + mock_config_entry, + api=SimpleNamespace( + get_total_water_and_co2_usage=AsyncMock( + return_value=_usage(total_eau=7, total_gas=1500) + ), + ), + ) + + data = await coordinator._async_update_data() + + assert data == EcobullesData( + water_liters=7, + co2_injection_time_seconds=1.5, + last_updated="2026-05-21T00:17:58", + ) + + +async def test_coordinator_update_fails_on_incomplete_payload( + hass: HomeAssistant, mock_config_entry +) -> None: + """Incomplete required API payloads mark the update as failed.""" + coordinator = _coordinator( + hass, + mock_config_entry, + api=SimpleNamespace(get_total_water_and_co2_usage=AsyncMock(return_value=None)), + ) + + with pytest.raises(UpdateFailed, match="api_payload_incomplete"): + await coordinator._async_update_data() + + partial_payload_coordinator = _coordinator( + hass, + mock_config_entry, + api=SimpleNamespace( + get_total_water_and_co2_usage=AsyncMock(return_value={"total_gas": 1}) + ), + ) + with pytest.raises(UpdateFailed, match="api_payload_incomplete"): + await partial_payload_coordinator._async_update_data() + + +async def test_coordinator_update_wraps_timeout_and_runtime_errors( + hass: HomeAssistant, mock_config_entry +) -> None: + """Coordinator failures are normalized to UpdateFailed.""" + timeout_coordinator = _coordinator( + hass, + mock_config_entry, + api=SimpleNamespace( + get_total_water_and_co2_usage=AsyncMock(side_effect=TimeoutError) + ), + ) + with pytest.raises(UpdateFailed, match="cannot_connect"): + await timeout_coordinator._async_update_data() + + failing_coordinator = _coordinator( + hass, + mock_config_entry, + api=SimpleNamespace( + get_total_water_and_co2_usage=AsyncMock(side_effect=RuntimeError("boom")) + ), + ) + with pytest.raises(UpdateFailed, match="update_error"): + await failing_coordinator._async_update_data() + + +async def test_sensor_native_values(hass: HomeAssistant, mock_config_entry) -> None: + """Sensor classes expose their values.""" + coordinator = _coordinator(hass, mock_config_entry) + coordinator.async_set_updated_data( + EcobullesData( + water_liters=42, + co2_injection_time_seconds=1.5, + last_updated="2026-05-21T00:17:58", + ) + ) + + sensors = { + description.key: EcobullesSensor(coordinator, "eco-ref", description) + for description in SENSORS + } + + assert sensors["water_usage"].native_value == 42 + assert sensors["co2_injection_time"].native_value == 1.5 + assert sensors["water_usage"].device_info == {"identifiers": {(DOMAIN, "eco-ref")}} diff --git a/tests/components/ecobulles/test_sensor_setup.py b/tests/components/ecobulles/test_sensor_setup.py new file mode 100755 index 0000000000000..b7ca71f6a7561 --- /dev/null +++ b/tests/components/ecobulles/test_sensor_setup.py @@ -0,0 +1,48 @@ +"""Home Assistant integration-level tests for Ecobulles sensors.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.ecobulles.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.usefixtures("enable_custom_integrations"), +] + + +async def test_sensor_setup(hass: HomeAssistant, mock_config_entry) -> None: + """The integration loads its entities without talking to the real cloud.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ecobulles.coordinator.EcobullesClient.get_total_water_and_co2_usage", + AsyncMock( + return_value={ + "total_gas": 35_464_000, + "total_eau": 161_649, + "last_updated": "2025-06-05T21:50:00", + } + ), + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + registry = er.async_get(hass) + water_usage_entity_id = registry.async_get_entity_id( + "sensor", DOMAIN, "test-eco-ref_water_usage" + ) + co2_injection_time_entity_id = registry.async_get_entity_id( + "sensor", DOMAIN, "test-eco-ref_co2_injection_time" + ) + + assert water_usage_entity_id is not None + assert co2_injection_time_entity_id is not None + assert hass.states.get(water_usage_entity_id).state == "161649" + assert hass.states.get(co2_injection_time_entity_id).state == "35464.0" + assert [entry.entry_id for entry in hass.config_entries.async_entries(DOMAIN)] == [ + mock_config_entry.entry_id + ]