From cd376fcc3e3016834e1c26e1ead0f9a1947a0cd6 Mon Sep 17 00:00:00 2001 From: Florian Casse Date: Thu, 16 Jul 2026 08:59:51 +0200 Subject: [PATCH 1/6] Add automatic stove discovery --- custom_components/duepi/__init__.py | 6 +- custom_components/duepi/api.py | 139 ++++++++++++++------ custom_components/duepi/config_flow.py | 173 ++++++++++++++++++++----- tasks/todo.md | 6 + tests/conftest.py | 17 +++ tests/test_api_hardening.py | 108 ++++++++++++++- tests/test_config_flow.py | 170 ++++++++++++++++++++++++ 7 files changed, 541 insertions(+), 78 deletions(-) create mode 100644 tasks/todo.md create mode 100644 tests/test_config_flow.py diff --git a/custom_components/duepi/__init__.py b/custom_components/duepi/__init__.py index b2da204..07851ac 100644 --- a/custom_components/duepi/__init__.py +++ b/custom_components/duepi/__init__.py @@ -13,7 +13,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .api import DuepiAuthError, DuepiCloudClient, DuepiConnectionError +from .api import DuepiCloudClient, DuepiConnectionError from .const import ( CONF_DEVICE_ID, CONF_DEFAULT_POWER, @@ -69,10 +69,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DuepiConfigEntry) -> boo await coordinator.async_config_entry_first_refresh() except DuepiConnectionError as err: raise ConfigEntryNotReady(f"Cannot connect to dpremoteiot.com: {err}") from err - except (DuepiAuthError, ConfigEntryAuthFailed): - raise - except Exception: - raise _LOGGER.info("Duepi integration ready") diff --git a/custom_components/duepi/api.py b/custom_components/duepi/api.py index c27b5fa..11045c7 100644 --- a/custom_components/duepi/api.py +++ b/custom_components/duepi/api.py @@ -7,6 +7,7 @@ import json import logging import re +from collections.abc import Iterator from dataclasses import dataclass import aiohttp @@ -136,6 +137,15 @@ class DuepiStoveState: online: bool | None +@dataclass(slots=True, frozen=True) +class DuepiDeviceSummary: + """Describe a stove available on the authenticated account.""" + + device_id: str + api_id: str | None + name: str | None + + class DuepiCloudClient: """Async client for the dpremoteiot.com cloud API.""" @@ -144,7 +154,7 @@ def __init__( session: aiohttp.ClientSession, email: str, password: str, - device_id: str, + device_id: str | None = None, ) -> None: self._session = session self._email = email @@ -155,10 +165,15 @@ def __init__( self._api_device_id: str | None = None # MongoDB ObjectId, resolved from dashboard @property - def device_id(self) -> str: + def device_id(self) -> str | None: """Return the device ID.""" return self._device_id + def select_device(self, device_id: str, api_id: str | None = None) -> None: + """Select the stove used by state reads and commands.""" + self._device_id = device_id + self._api_device_id = api_id + async def async_close(self) -> None: """Retain compatibility without closing Home Assistant's session.""" @@ -234,24 +249,43 @@ async def async_get_stove_state(self) -> DuepiStoveState: Raises DuepiAuthError, DuepiConnectionError, or DuepiParseError. """ - await self._ensure_auth() + page_html = await self._fetch_dashboard_resilient() + return self._parse_dashboard(page_html) - try: - page_html = await self._fetch_dashboard() - except DuepiSessionExpiredError: - self._authenticated = False - await self._ensure_auth() - page_html = await self._fetch_dashboard() - except (DuepiTransportError, DuepiServerError): - await asyncio.sleep(2) - try: - page_html = await self._fetch_dashboard() - except DuepiSessionExpiredError: - self._authenticated = False - await self._ensure_auth() - page_html = await self._fetch_dashboard() + async def async_list_devices(self) -> list[DuepiDeviceSummary]: + """Return the stoves available on the authenticated account.""" + page_html = await self._fetch_dashboard_resilient() + devices: list[DuepiDeviceSummary] = [] + seen: set[str] = set() + + for device in self._iter_dashboard_devices(page_html): + univocal_id = device.get("univocalID") + api_id_value = device.get("_id") + api_id = api_id_value if isinstance(api_id_value, str) and api_id_value else None + device_id = ( + univocal_id + if isinstance(univocal_id, str) and univocal_id + else api_id + ) + if device_id is None or device_id in seen: + continue - return self._parse_dashboard(page_html) + name_value = device.get("name") or device.get("deviceName") + name = ( + name_value.strip() + if isinstance(name_value, str) and name_value.strip() + else None + ) + seen.add(device_id) + devices.append( + DuepiDeviceSummary( + device_id=device_id, + api_id=api_id, + name=name, + ) + ) + + return devices async def async_turn_on(self, power: int | None = None, temperature: int | None = None) -> None: """Turn the stove on.""" @@ -289,6 +323,25 @@ async def _ensure_auth(self) -> None: if not await self.async_login(): raise DuepiInvalidCredentialsError("Login failed with provided credentials") + async def _fetch_dashboard_resilient(self) -> str: + """Fetch the dashboard with one safe retry or reauthentication.""" + await self._ensure_auth() + + try: + return await self._fetch_dashboard() + except DuepiSessionExpiredError: + self._authenticated = False + await self._ensure_auth() + return await self._fetch_dashboard() + except (DuepiTransportError, DuepiServerError): + await asyncio.sleep(2) + try: + return await self._fetch_dashboard() + except DuepiSessionExpiredError: + self._authenticated = False + await self._ensure_auth() + return await self._fetch_dashboard() + async def _fetch_dashboard(self) -> str: """Fetch the dashboard HTML, detecting session expiry.""" try: @@ -352,6 +405,8 @@ async def _send_command( await self._ensure_auth() effective_id = self._api_device_id or self._device_id + if effective_id is None: + raise DuepiParseError("No device has been selected") data = { "deviceId": effective_id, "active": "1" if active else "0", @@ -387,6 +442,9 @@ def _parse_dashboard(self, page_html: str) -> DuepiStoveState: comments with HTML-encoded quotes ("). A dashboard is accepted only when it contains the configured device and its current settings. """ + if self._device_id is None: + raise DuepiParseError("No device has been selected") + device_json = self._extract_device_json(page_html) if device_json is None: raise DuepiParseError("Configured device was not found in the dashboard") @@ -415,37 +473,42 @@ def _parse_dashboard(self, page_html: str) -> DuepiStoveState: online=online, ) - def _extract_device_json(self, page_html: str) -> dict | None: - """Extract device data from JSON embedded in HTML comments. - - The dashboard contains comments like: - - with quotes encoded as " - """ - # Find all HTML comments that contain our device ID + @staticmethod + def _iter_dashboard_devices(page_html: str) -> Iterator[dict[str, object]]: + """Yield valid device dictionaries embedded in dashboard comments.""" for comment_match in re.finditer(r"", page_html, re.DOTALL): - comment = comment_match.group(1).strip() - if self._device_id not in comment: - continue - - # Decode HTML entities (" -> ") - decoded = html.unescape(comment) - + decoded = html.unescape(comment_match.group(1).strip()) try: data = json.loads(decoded) except (json.JSONDecodeError, ValueError): - _LOGGER.debug("Found device ID in comment but JSON parse failed") continue candidates = data if isinstance(data, list) else [data] for device in candidates: if not isinstance(device, dict): continue - if ( - device.get("_id") == self._device_id - or device.get("univocalID") == self._device_id + has_identifier = any( + isinstance(device.get(key), str) and bool(device.get(key)) + for key in ("univocalID", "_id") + ) + if has_identifier and isinstance( + device.get("deviceCurrentSettings"), dict ): - return device + yield device + + def _extract_device_json(self, page_html: str) -> dict[str, object] | None: + """Extract device data from JSON embedded in HTML comments. + + The dashboard contains comments like: + + with quotes encoded as " + """ + for device in self._iter_dashboard_devices(page_html): + if ( + device.get("_id") == self._device_id + or device.get("univocalID") == self._device_id + ): + return device _LOGGER.debug("No matching device data found in dashboard") return None diff --git a/custom_components/duepi/config_flow.py b/custom_components/duepi/config_flow.py index db277e5..4c17114 100644 --- a/custom_components/duepi/config_flow.py +++ b/custom_components/duepi/config_flow.py @@ -12,11 +12,13 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .api import ( DuepiAuthError, DuepiCloudClient, DuepiConnectionError, + DuepiDeviceSummary, DuepiParseError, DuepiRateLimitError, DuepiServerError, @@ -43,10 +45,11 @@ { vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_DEVICE_ID): str, } ) +MANUAL_DEVICE_SCHEMA = vol.Schema({vol.Required(CONF_DEVICE_ID): str}) + REAUTH_SCHEMA = vol.Schema( { vol.Required(CONF_EMAIL): str, @@ -59,6 +62,10 @@ class DuepiConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Duepi Pellet Stove.""" VERSION = 1 + _client: DuepiCloudClient | None = None + _devices: dict[str, DuepiDeviceSummary] + _email: str | None = None + _password: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -67,33 +74,103 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: - device_id = user_input[CONF_DEVICE_ID].strip() + self._email = user_input[CONF_EMAIL] + self._password = user_input[CONF_PASSWORD] + session = async_create_clientsession( + self.hass, + cookie_jar=aiohttp.CookieJar(), + ) + self._client = DuepiCloudClient( + session, + self._email, + self._password, + ) - # Check if already configured - await self.async_set_unique_id(device_id) - self._abort_if_unique_id_configured() + try: + if not await self._client.async_login(): + errors["base"] = "invalid_auth" + else: + devices = await self._client.async_list_devices() + self._devices = {device.device_id: device for device in devices} + if devices: + return await self.async_step_select_device() + return await self.async_step_manual_device() + except DuepiParseError: + self._devices = {} + return await self.async_step_manual_device() + except Exception as err: # noqa: BLE001 + errors["base"] = self._error_from_exception(err) - # Validate credentials by attempting login + dashboard fetch - error = await self._async_validate_credentials( - user_input[CONF_EMAIL], - user_input[CONF_PASSWORD], - device_id, + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + errors=errors, + ) + + async def async_step_select_device( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select a discovered stove.""" + if self._client is None or not getattr(self, "_devices", None): + return await self.async_step_user() + + errors: dict[str, str] = {} + choices = { + device_id: ( + f"{summary.name} ({device_id})" if summary.name else device_id ) + for device_id, summary in self._devices.items() + } + + if user_input is not None: + device_id = user_input[CONF_DEVICE_ID].strip() + summary = self._devices.get(device_id) + if summary is None: + errors["base"] = "invalid_device" + else: + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured() + self._client.select_device(device_id, summary.api_id) + error = await self._async_validate_client(self._client, login=False) + if error is None: + return self._create_config_entry(device_id, summary.name) + errors["base"] = error + first_device_id = next(iter(choices)) + return self.async_show_form( + step_id="select_device", + data_schema=vol.Schema( + { + vol.Required( + CONF_DEVICE_ID, + default=first_device_id, + ): vol.In(choices) + } + ), + errors=errors, + ) + + async def async_step_manual_device( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Enter a stove identifier when discovery is unavailable.""" + if self._client is None: + return await self.async_step_user() + + errors: dict[str, str] = {} + if user_input is not None: + device_id = user_input[CONF_DEVICE_ID].strip() + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured() + self._client.select_device(device_id) + error = await self._async_validate_client(self._client, login=False) if error is None: - return self.async_create_entry( - title=f"Duepi Stove ({device_id[:8]}...)", - data={ - CONF_EMAIL: user_input[CONF_EMAIL], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_DEVICE_ID: device_id, - }, - ) + return self._create_config_entry(device_id) errors["base"] = error return self.async_show_form( - step_id="user", - data_schema=USER_SCHEMA, + step_id="manual_device", + data_schema=MANUAL_DEVICE_SCHEMA, errors=errors, ) @@ -139,31 +216,59 @@ async def _async_validate_credentials( self, email: str, password: str, device_id: str ) -> str | None: """Validate credentials. Returns an error key or None on success.""" - session = aiohttp.ClientSession() + session = async_create_clientsession( + self.hass, + cookie_jar=aiohttp.CookieJar(), + ) + client = DuepiCloudClient(session, email, password, device_id) + return await self._async_validate_client(client) + + async def _async_validate_client( + self, client: DuepiCloudClient, *, login: bool = True + ) -> str | None: + """Validate a client and map failures to config-flow error keys.""" try: - client = DuepiCloudClient(session, email, password, device_id) - if not await client.async_login(): + if login and not await client.async_login(): return "invalid_auth" - # Verify the configured device exists and has current settings. await client.async_get_stove_state() return None - except DuepiAuthError: + except Exception as err: # noqa: BLE001 + return self._error_from_exception(err) + + @staticmethod + def _error_from_exception(err: Exception) -> str: + """Map a cloud exception to a config-flow error key.""" + if isinstance(err, DuepiAuthError): return "invalid_auth" - except DuepiParseError: + if isinstance(err, DuepiParseError): return "invalid_device" - except DuepiTransportError: + if isinstance(err, DuepiTransportError): return "cannot_connect" - except DuepiServerError: + if isinstance(err, DuepiServerError): return "server_error" - except DuepiRateLimitError: + if isinstance(err, DuepiRateLimitError): return "rate_limited" - except DuepiConnectionError: + if isinstance(err, DuepiConnectionError): return "unknown" - except Exception: + _LOGGER.exception("Unexpected error during validation", exc_info=err) + return "unknown" + + def _create_config_entry( + self, device_id: str, name: str | None = None + ) -> FlowResult: + """Create an entry using the stable version-1 data shape.""" + if self._email is None or self._password is None: _LOGGER.exception("Unexpected error during validation") - return "unknown" - finally: - await session.close() + raise RuntimeError("Config-flow credentials are unavailable") + + return self.async_create_entry( + title=name or f"Duepi Stove ({device_id[:8]}...)", + data={ + CONF_EMAIL: self._email, + CONF_PASSWORD: self._password, + CONF_DEVICE_ID: device_id, + }, + ) @staticmethod @callback diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..72b702e --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,6 @@ +# Duepi v1.3.0 implementation + +- [x] Workstream A: automatic device discovery +- [ ] Workstream B: alarms, presets, last seen, and accumulating sensors +- [ ] Workstream C: translations, HACS compliance, release workflow, and README +- [ ] Push the branch and verify GitHub Actions diff --git a/tests/conftest.py b/tests/conftest.py index 9735428..fcf8c7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,9 +23,25 @@ def __class_getitem__(cls, _item: object) -> type["_ConfigEntry"]: class _ConfigFlow: """Minimal ConfigFlow base that accepts Home Assistant's domain keyword.""" + def __init__(self) -> None: + self.hass = _HomeAssistant() + self._unique_id = None + def __init_subclass__(cls, **_kwargs: object) -> None: super().__init_subclass__() + def async_show_form(self, **kwargs: object) -> dict[str, object]: + return {"type": "form", **kwargs} + + def async_create_entry(self, **kwargs: object) -> dict[str, object]: + return {"type": "create_entry", **kwargs} + + async def async_set_unique_id(self, unique_id: str) -> None: + self._unique_id = unique_id + + def _abort_if_unique_id_configured(self) -> None: + return None + class _OptionsFlow: """Minimal OptionsFlow base for importing the configuration module.""" @@ -160,6 +176,7 @@ def _install_voluptuous_stub(monkeypatch: pytest.MonkeyPatch) -> None: voluptuous.Optional = lambda value, **_kwargs: value voluptuous.All = lambda *values: values[-1] voluptuous.Coerce = lambda value: value + voluptuous.In = lambda value: value voluptuous.Range = lambda **_kwargs: (lambda value: value) monkeypatch.setitem(sys.modules, "voluptuous", voluptuous) diff --git a/tests/test_api_hardening.py b/tests/test_api_hardening.py index 9c75de8..0eba079 100644 --- a/tests/test_api_hardening.py +++ b/tests/test_api_hardening.py @@ -89,6 +89,11 @@ def dashboard(device_id: str = DEVICE_ID, *, online: object = "online") -> str: return f"" +def dashboard_devices(devices: list[dict[str, object]]) -> str: + """Return a dashboard comment containing the supplied devices.""" + return f"" + + def client(api: SimpleNamespace, session: FakeSession) -> object: """Build a client under test.""" return api.DuepiCloudClient(session, "user@example.invalid", "secret", DEVICE_ID) @@ -166,6 +171,107 @@ def test_wrong_device_id_never_fabricates_an_off_state( client(api, FakeSession([]))._parse_dashboard(dashboard("other-stove")) +def test_state_parse_requires_a_selected_device( + duepi_test_modules: SimpleNamespace, +) -> None: + """Discovery-only clients cannot accidentally parse or command a stove.""" + api = duepi_test_modules.api + cloud_client = api.DuepiCloudClient(FakeSession([]), "user", "secret") + + with pytest.raises(api.DuepiParseError, match="No device"): + cloud_client._parse_dashboard(dashboard()) + + +def test_list_devices_returns_named_deduplicated_summaries( + duepi_test_modules: SimpleNamespace, +) -> None: + """Discovery preserves order, optional names, and the public identifier.""" + api = duepi_test_modules.api + devices = [ + { + "_id": "api-one", + "univocalID": "stove-one", + "name": "Living room", + "deviceCurrentSettings": {}, + }, + { + "_id": "api-two", + "univocalID": "stove-two", + "deviceName": "Kitchen", + "deviceCurrentSettings": {}, + }, + { + "_id": "duplicate-api", + "univocalID": "stove-one", + "deviceCurrentSettings": {}, + }, + ] + session = FakeSession([FakeResponse(200, dashboard_devices(devices))]) + cloud_client = api.DuepiCloudClient(session, "user", "secret") + cloud_client._authenticated = True + + result = asyncio.run(cloud_client.async_list_devices()) + + assert result == [ + api.DuepiDeviceSummary("stove-one", "api-one", "Living room"), + api.DuepiDeviceSummary("stove-two", "api-two", "Kitchen"), + ] + + +def test_list_devices_falls_back_to_api_id_and_skips_malformed_comments( + duepi_test_modules: SimpleNamespace, +) -> None: + """Malformed comments and unrelated JSON do not break discovery.""" + api = duepi_test_modules.api + page = """ + + """ + dashboard_devices( + [{"_id": API_ID, "deviceCurrentSettings": {}, "name": " "}] + ) + session = FakeSession([FakeResponse(200, page)]) + cloud_client = api.DuepiCloudClient(session, "user", "secret") + cloud_client._authenticated = True + + assert asyncio.run(cloud_client.async_list_devices()) == [ + api.DuepiDeviceSummary(API_ID, API_ID, None) + ] + + +def test_list_devices_returns_empty_for_an_incompatible_dashboard( + duepi_test_modules: SimpleNamespace, +) -> None: + """No discoverable devices is a supported manual-entry fallback.""" + api = duepi_test_modules.api + session = FakeSession([FakeResponse(200, "")]) + cloud_client = api.DuepiCloudClient(session, "user", "secret") + cloud_client._authenticated = True + + assert asyncio.run(cloud_client.async_list_devices()) == [] + + +def test_list_devices_reuses_the_safe_read_retry( + monkeypatch: pytest.MonkeyPatch, duepi_test_modules: SimpleNamespace +) -> None: + """Discovery has the same one-retry policy as normal state reads.""" + api = duepi_test_modules.api + session = FakeSession( + [api.aiohttp.ClientError("network"), FakeResponse(200, dashboard())] + ) + cloud_client = api.DuepiCloudClient(session, "user", "secret") + cloud_client._authenticated = True + sleeps: list[float] = [] + + async def record_sleep(delay: float) -> None: + sleeps.append(delay) + + monkeypatch.setattr(api.asyncio, "sleep", record_sleep) + + assert asyncio.run(cloud_client.async_list_devices()) == [ + api.DuepiDeviceSummary(DEVICE_ID, API_ID, None) + ] + assert sleeps == [2] + + def test_incompatible_html_never_fabricates_an_off_state( duepi_test_modules: SimpleNamespace, ) -> None: @@ -400,7 +506,7 @@ async def async_get_stove_state(self) -> object: raise errors[outcome] return object() - monkeypatch.setattr(config_flow.aiohttp, "ClientSession", Session) + monkeypatch.setattr(config_flow, "async_create_clientsession", lambda *_args, **_kwargs: Session()) monkeypatch.setattr(config_flow, "DuepiCloudClient", StubClient) flow = config_flow.DuepiConfigFlow() diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..b4c29fa --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,170 @@ +"""Config-flow tests for automatic Duepi device discovery.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + + +def _flow_with_client( + duepi_test_modules: SimpleNamespace, + monkeypatch: object, + *, + devices: list[object], + state_error: Exception | None = None, + list_error: Exception | None = None, +) -> tuple[object, type]: + """Create a flow backed by a recording cloud client.""" + config_flow = duepi_test_modules.config_flow + + class StubClient: + instance: "StubClient" + + def __init__(self, *_args: object) -> None: + type(self).instance = self + self.login_calls = 0 + self.state_calls = 0 + self.selected: tuple[str, str | None] | None = None + + async def async_login(self) -> bool: + self.login_calls += 1 + return True + + async def async_list_devices(self) -> list[object]: + if list_error is not None: + raise list_error + return devices + + def select_device(self, device_id: str, api_id: str | None = None) -> None: + self.selected = (device_id, api_id) + + async def async_get_stove_state(self) -> object: + self.state_calls += 1 + if state_error is not None: + raise state_error + return object() + + monkeypatch.setattr(config_flow, "DuepiCloudClient", StubClient) + monkeypatch.setattr( + config_flow, + "async_create_clientsession", + lambda *_args, **_kwargs: object(), + ) + return config_flow.DuepiConfigFlow(), StubClient + + +def test_user_discovers_devices_then_creates_selected_entry( + monkeypatch: object, duepi_test_modules: SimpleNamespace +) -> None: + """Selection reuses the authenticated client and stable entry data shape.""" + api = duepi_test_modules.api + flow, client_type = _flow_with_client( + duepi_test_modules, + monkeypatch, + devices=[ + api.DuepiDeviceSummary("stove-one", "api-one", "Living room"), + api.DuepiDeviceSummary("stove-two", "api-two", None), + ], + ) + + first = asyncio.run( + flow.async_step_user({"email": "person@example.test", "password": "secret"}) + ) + result = asyncio.run(flow.async_step_select_device({"device_id": "stove-one"})) + + assert first["type"] == "form" + assert first["step_id"] == "select_device" + assert result == { + "type": "create_entry", + "title": "Living room", + "data": { + "email": "person@example.test", + "password": "secret", + "device_id": "stove-one", + }, + } + assert flow._unique_id == "stove-one" + assert client_type.instance.login_calls == 1 + assert client_type.instance.state_calls == 1 + assert client_type.instance.selected == ("stove-one", "api-one") + + +def test_empty_discovery_uses_manual_device_fallback( + monkeypatch: object, duepi_test_modules: SimpleNamespace +) -> None: + """Accounts without parseable summaries can still use their known identifier.""" + flow, client_type = _flow_with_client( + duepi_test_modules, + monkeypatch, + devices=[], + ) + + first = asyncio.run( + flow.async_step_user({"email": "person@example.test", "password": "secret"}) + ) + result = asyncio.run(flow.async_step_manual_device({"device_id": "manual-id"})) + + assert first["step_id"] == "manual_device" + assert result["type"] == "create_entry" + assert result["data"]["device_id"] == "manual-id" + assert client_type.instance.selected == ("manual-id", None) + assert client_type.instance.login_calls == 1 + + +def test_single_device_still_uses_explicit_selection( + monkeypatch: object, duepi_test_modules: SimpleNamespace +) -> None: + """A one-device account receives the same clear selection screen.""" + api = duepi_test_modules.api + flow, _client_type = _flow_with_client( + duepi_test_modules, + monkeypatch, + devices=[api.DuepiDeviceSummary("only-stove", "api-id", None)], + ) + + result = asyncio.run( + flow.async_step_user({"email": "person@example.test", "password": "secret"}) + ) + + assert result["step_id"] == "select_device" + + +def test_discovery_transport_error_remains_on_credentials_step( + monkeypatch: object, duepi_test_modules: SimpleNamespace +) -> None: + """A cloud outage is not misrepresented as an unknown device.""" + api = duepi_test_modules.api + flow, _client_type = _flow_with_client( + duepi_test_modules, + monkeypatch, + devices=[], + list_error=api.DuepiTransportError("offline"), + ) + + result = asyncio.run( + flow.async_step_user({"email": "person@example.test", "password": "secret"}) + ) + + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +def test_manual_device_maps_parse_failure( + monkeypatch: object, duepi_test_modules: SimpleNamespace +) -> None: + """The manual fallback keeps the existing invalid-device error.""" + api = duepi_test_modules.api + flow, _client_type = _flow_with_client( + duepi_test_modules, + monkeypatch, + devices=[], + state_error=api.DuepiParseError("missing"), + ) + + asyncio.run( + flow.async_step_user({"email": "person@example.test", "password": "secret"}) + ) + result = asyncio.run(flow.async_step_manual_device({"device_id": "missing"})) + + assert result["step_id"] == "manual_device" + assert result["errors"] == {"base": "invalid_device"} From 88af52ffd1af2946894134168ea4c4e2ebefec9c Mon Sep 17 00:00:00 2001 From: Florian Casse Date: Thu, 16 Jul 2026 09:07:41 +0200 Subject: [PATCH 2/6] Add stove diagnostics and usage tracking --- custom_components/duepi/__init__.py | 8 + custom_components/duepi/accumulation.py | 27 ++++ custom_components/duepi/api.py | 27 ++++ custom_components/duepi/binary_sensor.py | 28 +++- custom_components/duepi/climate.py | 22 ++- custom_components/duepi/config_flow.py | 47 +++++- custom_components/duepi/const.py | 13 ++ custom_components/duepi/coordinator.py | 132 ++++++++++----- custom_components/duepi/diagnostics.py | 10 +- custom_components/duepi/sensor.py | 195 +++++++++++++++++++++-- tasks/todo.md | 2 +- tests/conftest.py | 9 ++ tests/test_accumulation.py | 43 +++++ tests/test_api_hardening.py | 83 ++++++++++ tests/test_config_flow.py | 45 ++++++ tests/test_connectivity.py | 31 +++- tests/test_integration_regressions.py | 6 + tests/test_presets.py | 181 +++++++++++++++++++++ 18 files changed, 847 insertions(+), 62 deletions(-) create mode 100644 custom_components/duepi/accumulation.py create mode 100644 tests/test_accumulation.py create mode 100644 tests/test_presets.py diff --git a/custom_components/duepi/__init__.py b/custom_components/duepi/__init__.py index 07851ac..d07dd9b 100644 --- a/custom_components/duepi/__init__.py +++ b/custom_components/duepi/__init__.py @@ -18,7 +18,11 @@ CONF_DEVICE_ID, CONF_DEFAULT_POWER, CONF_DEFAULT_TEMPERATURE, + CONF_ECO_POWER, + CONF_ECO_TEMPERATURE, CONF_SCAN_INTERVAL, + DEFAULT_ECO_POWER, + DEFAULT_ECO_TEMPERATURE, DEFAULT_POWER, DEFAULT_SCAN_INTERVAL, DEFAULT_TEMPERATURE, @@ -63,6 +67,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: DuepiConfigEntry) -> boo default_temperature=entry.options.get( CONF_DEFAULT_TEMPERATURE, DEFAULT_TEMPERATURE ), + eco_power=entry.options.get(CONF_ECO_POWER, DEFAULT_ECO_POWER), + eco_temperature=entry.options.get( + CONF_ECO_TEMPERATURE, DEFAULT_ECO_TEMPERATURE + ), ) _LOGGER.debug("Running first data refresh (interval=%ss)", scan_interval) diff --git a/custom_components/duepi/accumulation.py b/custom_components/duepi/accumulation.py new file mode 100644 index 0000000..beced59 --- /dev/null +++ b/custom_components/duepi/accumulation.py @@ -0,0 +1,27 @@ +"""Pure accumulation helpers for Duepi runtime sensors.""" + +from __future__ import annotations + + +def pellet_rate( + power: int | None, + kg_per_hour_min: float, + kg_per_hour_max: float, +) -> float: + """Estimate pellet use for a valid power level from one through five.""" + if power is None or not 1 <= power <= 5: + return 0.0 + position = (power - 1) / 4 + return kg_per_hour_min + position * (kg_per_hour_max - kg_per_hour_min) + + +def capped_elapsed_seconds( + previous_tick: float | None, + current_tick: float, + scan_interval_seconds: float, +) -> float: + """Return non-negative elapsed time capped at two polling intervals.""" + if previous_tick is None: + return 0.0 + elapsed = max(0.0, current_tick - previous_tick) + return min(elapsed, max(0.0, 2 * scan_interval_seconds)) diff --git a/custom_components/duepi/api.py b/custom_components/duepi/api.py index 11045c7..bdcc1c8 100644 --- a/custom_components/duepi/api.py +++ b/custom_components/duepi/api.py @@ -49,6 +49,9 @@ _RE_CSRF_INPUT_ALT = re.compile(r']*value=["\']([^"\']+)["\'][^>]*name=["\']_csrf["\']', re.IGNORECASE) _RE_CSRF_META = re.compile(r']*name=["\']csrf-token["\'][^>]*content=["\']([^"\']+)["\']', re.IGNORECASE) +_ALARM_KEYS = ("alarm", "alarmCode", "alarmDescription", "error", "errorCode") +_CLEAR_ALARM_VALUES = {"", "0", "false", "none", "no alarm", "ok"} + def _safe_int(value: object) -> int | None: """Convert a value to int, returning None on failure.""" @@ -88,6 +91,28 @@ def _normalize_online(value: object) -> bool | None: return None +def _normalize_alarm(value: object) -> str | None: + """Normalize an alarm value without treating common clear values as faults.""" + if value is None or value is False or value == 0: + return None + if not isinstance(value, (str, int, float)): + return None + normalized = str(value).strip() + if normalized.lower() in _CLEAR_ALARM_VALUES: + return None + return normalized or None + + +def _extract_alarm(*sources: dict[str, object]) -> str | None: + """Return the first active alarm from the supplied mappings.""" + for source in sources: + for key in _ALARM_KEYS: + alarm = _normalize_alarm(source.get(key)) + if alarm is not None: + return alarm + return None + + class DuepiApiError(Exception): """Base exception for Duepi API errors.""" @@ -135,6 +160,7 @@ class DuepiStoveState: set_temperature: int | None raw_online: bool | None online: bool | None + alarm: str | None = None @dataclass(slots=True, frozen=True) @@ -471,6 +497,7 @@ def _parse_dashboard(self, page_html: str) -> DuepiStoveState: set_temperature=_safe_int(settings.get("settedTemperature")), raw_online=online, online=online, + alarm=_extract_alarm(settings, device_json), ) @staticmethod diff --git a/custom_components/duepi/binary_sensor.py b/custom_components/duepi/binary_sensor.py index 2f03f0b..821c07d 100644 --- a/custom_components/duepi/binary_sensor.py +++ b/custom_components/duepi/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_DEVICE_ID -from .coordinator import DuepiCoordinator +from .coordinator import DuepiCoordinator, state_has_problem from .device import build_device_info @@ -25,7 +25,12 @@ async def async_setup_entry( """Set up the Duepi online binary sensor.""" coordinator: DuepiCoordinator = entry.runtime_data device_id = entry.data[CONF_DEVICE_ID] - async_add_entities([DuepiOnlineBinarySensor(coordinator, device_id)]) + async_add_entities( + [ + DuepiOnlineBinarySensor(coordinator, device_id), + DuepiProblemBinarySensor(coordinator, device_id), + ] + ) class DuepiOnlineBinarySensor(CoordinatorEntity[DuepiCoordinator], BinarySensorEntity): @@ -47,3 +52,22 @@ def is_on(self) -> bool | None: if self.coordinator.data is None: return None return self.coordinator.data.online + + +class DuepiProblemBinarySensor(CoordinatorEntity[DuepiCoordinator], BinarySensorEntity): + """Binary sensor indicating whether the stove reports a problem.""" + + _attr_has_entity_name = True + _attr_translation_key = "problem" + _attr_device_class = BinarySensorDeviceClass.PROBLEM + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: DuepiCoordinator, device_id: str) -> None: + super().__init__(coordinator) + self._attr_unique_id = f"{device_id}_problem" + self._attr_device_info = build_device_info(device_id) + + @property + def is_on(self) -> bool | None: + """Return True if the stove reports an alarm or error status.""" + return state_has_problem(self.coordinator.data) diff --git a/custom_components/duepi/climate.py b/custom_components/duepi/climate.py index 313c70e..335dccc 100644 --- a/custom_components/duepi/climate.py +++ b/custom_components/duepi/climate.py @@ -16,7 +16,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_DEVICE_ID, MAX_TEMPERATURE, MIN_POWER, MAX_POWER, MIN_TEMPERATURE +from .const import ( + CONF_DEVICE_ID, + MAX_POWER, + MAX_TEMPERATURE, + MIN_POWER, + MIN_TEMPERATURE, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, +) from .coordinator import DuepiCoordinator from .device import build_device_info @@ -44,11 +53,13 @@ class DuepiClimateEntity(CoordinatorEntity[DuepiCoordinator], ClimateEntity): _attr_max_temp = MAX_TEMPERATURE _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] _attr_fan_modes = FAN_MODES + _attr_preset_modes = [PRESET_NONE, PRESET_ECO, PRESET_COMFORT] _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE ) def __init__(self, coordinator: DuepiCoordinator, entry: ConfigEntry) -> None: @@ -109,6 +120,11 @@ def fan_mode(self) -> str | None: return str(self.coordinator.data.working_power) return None + @property + def preset_mode(self) -> str: + """Return the current or pending preset.""" + return self.coordinator.current_preset() + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode (heat or off).""" if hvac_mode == HVACMode.HEAT: @@ -126,6 +142,10 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: """Set the fan mode (power level 1-5).""" await self.coordinator.async_set_power(int(fan_mode)) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Apply or queue a climate preset.""" + await self.coordinator.async_set_preset(preset_mode) + async def async_turn_on(self) -> None: """Turn the stove on.""" await self.coordinator.async_turn_on() diff --git a/custom_components/duepi/config_flow.py b/custom_components/duepi/config_flow.py index 4c17114..f147c08 100644 --- a/custom_components/duepi/config_flow.py +++ b/custom_components/duepi/config_flow.py @@ -28,7 +28,15 @@ CONF_DEFAULT_POWER, CONF_DEFAULT_TEMPERATURE, CONF_DEVICE_ID, + CONF_ECO_POWER, + CONF_ECO_TEMPERATURE, + CONF_PELLET_KG_PER_HOUR_MAX, + CONF_PELLET_KG_PER_HOUR_MIN, CONF_SCAN_INTERVAL, + DEFAULT_ECO_POWER, + DEFAULT_ECO_TEMPERATURE, + DEFAULT_PELLET_KG_PER_HOUR_MAX, + DEFAULT_PELLET_KG_PER_HOUR_MIN, DEFAULT_POWER, DEFAULT_SCAN_INTERVAL, DEFAULT_TEMPERATURE, @@ -284,10 +292,17 @@ async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options.""" + errors: dict[str, str] = {} if user_input is not None: - return self.async_create_entry(data=user_input) + if ( + user_input[CONF_PELLET_KG_PER_HOUR_MIN] + > user_input[CONF_PELLET_KG_PER_HOUR_MAX] + ): + errors["base"] = "invalid_pellet_range" + else: + return self.async_create_entry(data=user_input) - options = self.config_entry.options + options = user_input or self.config_entry.options return self.async_show_form( step_id="init", data_schema=vol.Schema( @@ -307,6 +322,34 @@ async def async_step_init( vol.Coerce(int), vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE), ), + vol.Optional( + CONF_ECO_POWER, + default=options.get(CONF_ECO_POWER, DEFAULT_ECO_POWER), + ): vol.All(vol.Coerce(int), vol.Range(min=MIN_POWER, max=MAX_POWER)), + vol.Optional( + CONF_ECO_TEMPERATURE, + default=options.get( + CONF_ECO_TEMPERATURE, DEFAULT_ECO_TEMPERATURE + ), + ): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE), + ), + vol.Optional( + CONF_PELLET_KG_PER_HOUR_MIN, + default=options.get( + CONF_PELLET_KG_PER_HOUR_MIN, + DEFAULT_PELLET_KG_PER_HOUR_MIN, + ), + ): vol.All(vol.Coerce(float), vol.Range(min=0, max=10)), + vol.Optional( + CONF_PELLET_KG_PER_HOUR_MAX, + default=options.get( + CONF_PELLET_KG_PER_HOUR_MAX, + DEFAULT_PELLET_KG_PER_HOUR_MAX, + ), + ): vol.All(vol.Coerce(float), vol.Range(min=0, max=10)), } ), + errors=errors, ) diff --git a/custom_components/duepi/const.py b/custom_components/duepi/const.py index a1a0cd3..c3ba612 100644 --- a/custom_components/duepi/const.py +++ b/custom_components/duepi/const.py @@ -10,16 +10,29 @@ CONF_SCAN_INTERVAL = "scan_interval" CONF_DEFAULT_POWER = "default_power" CONF_DEFAULT_TEMPERATURE = "default_temperature" +CONF_ECO_POWER = "eco_power" +CONF_ECO_TEMPERATURE = "eco_temperature" +CONF_PELLET_KG_PER_HOUR_MIN = "pellet_kg_per_hour_min" +CONF_PELLET_KG_PER_HOUR_MAX = "pellet_kg_per_hour_max" # Defaults DEFAULT_SCAN_INTERVAL = 30 # seconds DEFAULT_POWER = 5 DEFAULT_TEMPERATURE = 25 +DEFAULT_ECO_POWER = 1 +DEFAULT_ECO_TEMPERATURE = 18 +DEFAULT_PELLET_KG_PER_HOUR_MIN = 0.6 +DEFAULT_PELLET_KG_PER_HOUR_MAX = 1.8 MIN_TEMPERATURE = 5 MAX_TEMPERATURE = 40 MIN_POWER = 1 MAX_POWER = 5 +# Climate presets +PRESET_NONE = "none" +PRESET_ECO = "eco" +PRESET_COMFORT = "comfort" + # API URLs URL_BASE = "https://dpremoteiot.com" URL_LOGIN = f"{URL_BASE}/login" diff --git a/custom_components/duepi/coordinator.py b/custom_components/duepi/coordinator.py index cf486d4..fa7d37f 100644 --- a/custom_components/duepi/coordinator.py +++ b/custom_components/duepi/coordinator.py @@ -24,7 +24,16 @@ DuepiStoveState, DuepiTransportError, ) -from .const import DEFAULT_POWER, DEFAULT_TEMPERATURE, DOMAIN +from .const import ( + DEFAULT_ECO_POWER, + DEFAULT_ECO_TEMPERATURE, + DEFAULT_POWER, + DEFAULT_TEMPERATURE, + DOMAIN, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, +) _LOGGER = logging.getLogger(__name__) @@ -48,6 +57,8 @@ def __init__( *, default_power: int = DEFAULT_POWER, default_temperature: int = DEFAULT_TEMPERATURE, + eco_power: int = DEFAULT_ECO_POWER, + eco_temperature: int = DEFAULT_ECO_TEMPERATURE, ) -> None: super().__init__( hass, @@ -59,6 +70,9 @@ def __init__( self.client = client self._default_power = default_power self._default_temperature = default_temperature + self._eco_power = eco_power + self._eco_temperature = eco_temperature + self._pending_preset: str | None = None self._desired_power: int | None = None self._was_heating: bool = False self._has_seen_connected = False @@ -68,6 +82,7 @@ def __init__( self._disconnect_confirmed = False self._was_disconnected = False self._last_successful_update_time: datetime | None = None + self._last_seen: datetime | None = None @property def raw_online(self) -> bool | None: @@ -84,6 +99,11 @@ def last_successful_update_time(self) -> datetime | None: """Return when a stove-state poll most recently completed successfully.""" return self._last_successful_update_time + @property + def last_seen(self) -> datetime | None: + """Return the last time the cloud explicitly reported the stove online.""" + return self._last_seen + @property def disconnect_grace_started_at(self) -> float | None: """Return the monotonic timestamp at which the current grace period began.""" @@ -161,6 +181,7 @@ def _filter_connectivity(self, state: DuepiStoveState) -> DuepiStoveState: raw_online = state.raw_online if raw_online is True: + self._last_seen = datetime.now(timezone.utc) was_disconnected = ( self._disconnect_grace_started_at is not None or self._disconnect_confirmed @@ -243,21 +264,27 @@ def _cancel_disconnect_timer(self) -> None: async def async_turn_on(self) -> None: """Turn the stove on and refresh.""" state = self.data - power = self._default_power - temperature = self._default_temperature + pending_preset = self._pending_preset + targets = ( + self.preset_targets(pending_preset) + if pending_preset is not None + else None + ) + power, temperature = targets or ( + self._default_power, + self._default_temperature, + ) self._desired_power = power _LOGGER.info("Turning stove ON (power=%d, temp=%d)", power, temperature) await self.client.async_turn_on(power=power, temperature=temperature) + self._pending_preset = None if state: self.async_set_updated_data( - DuepiStoveState( + replace( + state, power_on=True, - status_text=state.status_text, - room_temperature=state.room_temperature, working_power=power, set_temperature=temperature, - raw_online=state.raw_online, - online=state.online, ) ) @@ -267,17 +294,7 @@ async def async_turn_off(self) -> None: await self.client.async_turn_off() state = self.data if state: - self.async_set_updated_data( - DuepiStoveState( - power_on=False, - status_text=state.status_text, - room_temperature=state.room_temperature, - working_power=state.working_power, - set_temperature=state.set_temperature, - raw_online=state.raw_online, - online=state.online, - ) - ) + self.async_set_updated_data(replace(state, power_on=False)) async def async_set_power(self, power: int) -> None: """Set working power and refresh.""" @@ -286,17 +303,7 @@ async def async_set_power(self, power: int) -> None: await self.client.async_set_power(power, current_state=self.data) state = self.data if state: - self.async_set_updated_data( - DuepiStoveState( - power_on=state.power_on, - status_text=state.status_text, - room_temperature=state.room_temperature, - working_power=power, - set_temperature=state.set_temperature, - raw_online=state.raw_online, - online=state.online, - ) - ) + self.async_set_updated_data(replace(state, working_power=power)) async def async_set_temperature(self, temperature: int) -> None: """Set target temperature and refresh.""" @@ -304,19 +311,64 @@ async def async_set_temperature(self, temperature: int) -> None: await self.client.async_set_temperature(temperature, current_state=self.data) state = self.data if state: - self.async_set_updated_data( - DuepiStoveState( - power_on=state.power_on, - status_text=state.status_text, - room_temperature=state.room_temperature, - working_power=state.working_power, - set_temperature=temperature, - raw_online=state.raw_online, - online=state.online, - ) + self.async_set_updated_data(replace(state, set_temperature=temperature)) + + def preset_targets(self, preset: str) -> tuple[int, int] | None: + """Return the configured targets for a climate preset.""" + if preset == PRESET_NONE: + return None + if preset == PRESET_ECO: + return self._eco_power, self._eco_temperature + if preset == PRESET_COMFORT: + return self._default_power, self._default_temperature + raise ValueError(f"Unsupported preset: {preset}") + + def current_preset(self) -> str: + """Return the pending or currently matching climate preset.""" + if self._pending_preset is not None: + return self._pending_preset + state = self.data + if state is None: + return PRESET_NONE + current_targets = (state.working_power, state.set_temperature) + if current_targets == self.preset_targets(PRESET_ECO): + return PRESET_ECO + if current_targets == self.preset_targets(PRESET_COMFORT): + return PRESET_COMFORT + return PRESET_NONE + + async def async_set_preset(self, preset: str) -> None: + """Apply a preset now or defer it until the next turn-on.""" + targets = self.preset_targets(preset) + if targets is None: + self._pending_preset = None + return + + state = self.data + if state is None or not state.power_on: + self._pending_preset = preset + return + + power, temperature = targets + self._desired_power = power + await self.client.async_turn_on(power=power, temperature=temperature) + self.async_set_updated_data( + replace( + state, + working_power=power, + set_temperature=temperature, ) + ) async def _async_enforce_power(self, power: int) -> None: """Re-send desired power after stove reaches nominal heating.""" await self.client.async_set_power(power, current_state=self.data) await self.async_request_refresh() + + +def state_has_problem(state: DuepiStoveState | None) -> bool | None: + """Return whether a stove state reports an alarm or error status.""" + if state is None: + return None + status = state.status_text.lower() if state.status_text else "" + return bool(state.alarm) or "alarm" in status or "error" in status diff --git a/custom_components/duepi/diagnostics.py b/custom_components/duepi/diagnostics.py index a2eaed7..04c216b 100644 --- a/custom_components/duepi/diagnostics.py +++ b/custom_components/duepi/diagnostics.py @@ -18,6 +18,11 @@ def _safe_error_name(error: BaseException | None) -> str | None: return type(error).__name__ if error is not None else None +def _serialize_datetime(value: object) -> object: + """Serialize a datetime-like diagnostic value when available.""" + return value.isoformat() if hasattr(value, "isoformat") else value + + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: @@ -39,10 +44,9 @@ async def async_get_config_entry_diagnostics( "state": asdict(coordinator.data) if coordinator.data else None, "last_update_success": coordinator.last_update_success, "last_successful_update": ( - last_successful_update.isoformat() - if hasattr(last_successful_update, "isoformat") - else last_successful_update + _serialize_datetime(last_successful_update) ), + "last_seen": _serialize_datetime(getattr(coordinator, "last_seen", None)), "last_error": _safe_error_name( getattr(coordinator, "last_exception", None) ), diff --git a/custom_components/duepi/sensor.py b/custom_components/duepi/sensor.py index 85e2f79..643af71 100644 --- a/custom_components/duepi/sensor.py +++ b/custom_components/duepi/sensor.py @@ -4,21 +4,36 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime +from time import monotonic +from typing import Literal from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + EntityCategory, + UnitOfMass, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import DuepiStoveState -from .const import CONF_DEVICE_ID +from .accumulation import capped_elapsed_seconds, pellet_rate +from .const import ( + CONF_DEVICE_ID, + CONF_PELLET_KG_PER_HOUR_MAX, + CONF_PELLET_KG_PER_HOUR_MIN, + DEFAULT_PELLET_KG_PER_HOUR_MAX, + DEFAULT_PELLET_KG_PER_HOUR_MIN, +) from .coordinator import DuepiCoordinator from .device import build_device_info @@ -27,7 +42,9 @@ class DuepiSensorDescription(SensorEntityDescription): """Describe a Duepi sensor.""" - value_fn: Callable[[DuepiStoveState], str | int | float | None] + value_fn: Callable[ + [DuepiCoordinator], str | int | float | datetime | None + ] SENSOR_DESCRIPTIONS: tuple[DuepiSensorDescription, ...] = ( @@ -37,21 +54,21 @@ class DuepiSensorDescription(SensorEntityDescription): device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda s: s.room_temperature, + value_fn=lambda coordinator: coordinator.data.room_temperature, ), DuepiSensorDescription( key="power_level", translation_key="power_level", icon="mdi:fire", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda s: s.working_power, + value_fn=lambda coordinator: coordinator.data.working_power, ), DuepiSensorDescription( key="status", translation_key="status", icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda s: s.status_text, + value_fn=lambda coordinator: coordinator.data.status_text, ), DuepiSensorDescription( key="set_temperature", @@ -59,8 +76,42 @@ class DuepiSensorDescription(SensorEntityDescription): device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda s: s.set_temperature, + value_fn=lambda coordinator: coordinator.data.set_temperature, + ), + DuepiSensorDescription( + key="alarm", + translation_key="alarm", + icon="mdi:alert-circle-outline", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda coordinator: coordinator.data.alarm, ), + DuepiSensorDescription( + key="last_seen", + translation_key="last_seen", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.last_seen, + ), +) + +OPERATING_HOURS_DESCRIPTION = SensorEntityDescription( + key="operating_hours", + translation_key="operating_hours", + icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.TOTAL_INCREASING, +) + +PELLET_CONSUMPTION_DESCRIPTION = SensorEntityDescription( + key="pellet_consumption", + translation_key="pellet_consumption", + icon="mdi:chart-bell-curve-cumulative", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ) @@ -72,10 +123,35 @@ async def async_setup_entry( """Set up Duepi sensor entities.""" coordinator: DuepiCoordinator = entry.runtime_data device_id = entry.data[CONF_DEVICE_ID] - async_add_entities( + entities: list[SensorEntity] = [ DuepiSensorEntity(coordinator, device_id, desc) for desc in SENSOR_DESCRIPTIONS + ] + entities.extend( + [ + DuepiAccumulatingSensor( + coordinator, + device_id, + OPERATING_HOURS_DESCRIPTION, + kind="hours", + ), + DuepiAccumulatingSensor( + coordinator, + device_id, + PELLET_CONSUMPTION_DESCRIPTION, + kind="pellets", + kg_per_hour_min=entry.options.get( + CONF_PELLET_KG_PER_HOUR_MIN, + DEFAULT_PELLET_KG_PER_HOUR_MIN, + ), + kg_per_hour_max=entry.options.get( + CONF_PELLET_KG_PER_HOUR_MAX, + DEFAULT_PELLET_KG_PER_HOUR_MAX, + ), + ), + ] ) + async_add_entities(entities) class DuepiSensorEntity(CoordinatorEntity[DuepiCoordinator], SensorEntity): @@ -96,8 +172,103 @@ def __init__( self._attr_device_info = build_device_info(device_id) @property - def native_value(self) -> str | int | float | None: + def native_value(self) -> str | int | float | datetime | None: """Return the sensor value.""" if self.coordinator.data is None: return None - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self.coordinator) + + +class DuepiAccumulatingSensor( + CoordinatorEntity[DuepiCoordinator], RestoreSensor +): + """Restore and accumulate operating time or estimated pellet use.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DuepiCoordinator, + device_id: str, + description: SensorEntityDescription, + *, + kind: Literal["hours", "pellets"], + kg_per_hour_min: float = DEFAULT_PELLET_KG_PER_HOUR_MIN, + kg_per_hour_max: float = DEFAULT_PELLET_KG_PER_HOUR_MAX, + ) -> None: + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + self._attr_device_info = build_device_info(device_id) + self._kind = kind + self._kg_per_hour_min = kg_per_hour_min + self._kg_per_hour_max = kg_per_hour_max + self._total = 0.0 + self._last_tick: float | None = None + self._previous_active = False + self._previous_power: int | None = None + update_interval = coordinator.update_interval + self._scan_interval_seconds = ( + update_interval.total_seconds() if update_interval is not None else 30.0 + ) + + async def async_added_to_hass(self) -> None: + """Restore the total and start timing from this process lifetime.""" + await super().async_added_to_hass() + restored = await self.async_get_last_sensor_data() + if restored is not None and restored.native_value is not None: + try: + self._total = max(0.0, float(restored.native_value)) + except (TypeError, ValueError): + self._total = 0.0 + + state = self.coordinator.data + self._previous_active = bool(state and state.power_on) + self._previous_power = state.working_power if state else None + self._last_tick = monotonic() + + @property + def native_value(self) -> float: + """Return the restored cumulative value.""" + return round(self._total, 3) + + @property + def extra_state_attributes(self) -> dict[str, int | float | None] | None: + """Expose inputs used by the pellet estimate.""" + if self._kind != "pellets": + return None + state = self.coordinator.data + power = state.working_power if state else None + return { + "power_level": power, + "estimated_rate_kg_per_hour": pellet_rate( + power, + self._kg_per_hour_min, + self._kg_per_hour_max, + ), + } + + @callback + def _handle_coordinator_update(self) -> None: + """Accumulate the interval represented by the previously observed state.""" + now = monotonic() + elapsed = capped_elapsed_seconds( + self._last_tick, + now, + self._scan_interval_seconds, + ) + if self._previous_active: + if self._kind == "hours": + self._total += elapsed / 3600 + else: + self._total += elapsed / 3600 * pellet_rate( + self._previous_power, + self._kg_per_hour_min, + self._kg_per_hour_max, + ) + + state = self.coordinator.data + self._previous_active = bool(state and state.power_on) + self._previous_power = state.working_power if state else None + self._last_tick = now + super()._handle_coordinator_update() diff --git a/tasks/todo.md b/tasks/todo.md index 72b702e..b8ca891 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,6 +1,6 @@ # Duepi v1.3.0 implementation - [x] Workstream A: automatic device discovery -- [ ] Workstream B: alarms, presets, last seen, and accumulating sensors +- [x] Workstream B: alarms, presets, last seen, and accumulating sensors - [ ] Workstream C: translations, HACS compliance, release workflow, and README - [ ] Push the branch and verify GitHub Actions diff --git a/tests/conftest.py b/tests/conftest.py index fcf8c7c..3cb7086 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,6 +46,15 @@ def _abort_if_unique_id_configured(self) -> None: class _OptionsFlow: """Minimal OptionsFlow base for importing the configuration module.""" + def __init__(self, config_entry: object | None = None) -> None: + self.config_entry = config_entry or SimpleNamespace(options={}) + + def async_show_form(self, **kwargs: object) -> dict[str, object]: + return {"type": "form", **kwargs} + + def async_create_entry(self, **kwargs: object) -> dict[str, object]: + return {"type": "create_entry", **kwargs} + class _HomeAssistant: """Test stand-in for Home Assistant.""" diff --git a/tests/test_accumulation.py b/tests/test_accumulation.py new file mode 100644 index 0000000..04573e3 --- /dev/null +++ b/tests/test_accumulation.py @@ -0,0 +1,43 @@ +"""Tests for pure Duepi accumulation math.""" + +from __future__ import annotations + +import importlib +from types import ModuleType, SimpleNamespace + +import pytest + + +@pytest.fixture +def accumulation_module(duepi_test_modules: SimpleNamespace) -> ModuleType: + """Import the pure helper within the isolated Duepi package fixture.""" + return importlib.import_module("custom_components.duepi.accumulation") + + +@pytest.mark.parametrize( + ("power", "expected"), + [(1, 0.6), (2, 0.9), (3, 1.2), (4, 1.5), (5, 1.8)], +) +def test_pellet_rate_interpolates_between_configured_bounds( + accumulation_module: ModuleType, power: int, expected: float +) -> None: + """Power levels use a linear rate between levels one and five.""" + assert accumulation_module.pellet_rate(power, 0.6, 1.8) == pytest.approx(expected) + + +@pytest.mark.parametrize("power", [None, 0, 6]) +def test_pellet_rate_is_zero_without_a_valid_power( + accumulation_module: ModuleType, power: int | None +) -> None: + """Unknown or invalid power never invents pellet consumption.""" + assert accumulation_module.pellet_rate(power, 0.6, 1.8) == 0 + + +def test_elapsed_time_is_non_negative_and_capped( + accumulation_module: ModuleType, +) -> None: + """Restarts, clock anomalies, and long outages cannot over-count.""" + assert accumulation_module.capped_elapsed_seconds(None, 100, 30) == 0 + assert accumulation_module.capped_elapsed_seconds(100, 90, 30) == 0 + assert accumulation_module.capped_elapsed_seconds(100, 125, 30) == 25 + assert accumulation_module.capped_elapsed_seconds(100, 500, 30) == 60 diff --git a/tests/test_api_hardening.py b/tests/test_api_hardening.py index 0eba079..9a52dbf 100644 --- a/tests/test_api_hardening.py +++ b/tests/test_api_hardening.py @@ -307,6 +307,89 @@ def test_is_online_is_strictly_normalized( assert state.online is expected +@pytest.mark.parametrize( + ("key", "value", "expected"), + [ + ("alarm", "A01", "A01"), + ("alarmCode", 12, "12"), + ("alarmDescription", "Overheat", "Overheat"), + ("error", "Fan blocked", "Fan blocked"), + ("errorCode", 7, "7"), + ], +) +def test_alarm_candidates_are_parsed_from_current_settings( + duepi_test_modules: SimpleNamespace, + key: str, + value: object, + expected: str, +) -> None: + """Known alarm fields are normalized to one diagnostic value.""" + api = duepi_test_modules.api + payload = [ + { + "_id": API_ID, + "univocalID": DEVICE_ID, + "deviceCurrentSettings": { + "powerState": "ON", + "isOnline": True, + key: value, + }, + } + ] + + state = client(api, FakeSession([]))._parse_dashboard(dashboard_devices(payload)) + + assert state.alarm == expected + + +@pytest.mark.parametrize("clear_value", [None, "", "0", 0, False, "none", "No Alarm", "OK"]) +def test_clear_alarm_values_do_not_report_a_problem( + duepi_test_modules: SimpleNamespace, clear_value: object +) -> None: + """Common clear encodings must remain falsey.""" + api = duepi_test_modules.api + payload = [ + { + "_id": API_ID, + "univocalID": DEVICE_ID, + "alarm": "top-level fallback", + "deviceCurrentSettings": { + "powerState": "OFF", + "isOnline": True, + "alarm": clear_value, + }, + } + ] + + state = client(api, FakeSession([]))._parse_dashboard(dashboard_devices(payload)) + + assert state.alarm == "top-level fallback" + + +def test_alarm_priority_prefers_settings_then_candidate_order( + duepi_test_modules: SimpleNamespace, +) -> None: + """Specific current settings win over stale top-level device data.""" + api = duepi_test_modules.api + payload = [ + { + "_id": API_ID, + "univocalID": DEVICE_ID, + "alarm": "stale", + "deviceCurrentSettings": { + "powerState": "ON", + "isOnline": True, + "alarm": "active", + "error": "lower priority", + }, + } + ] + + state = client(api, FakeSession([]))._parse_dashboard(dashboard_devices(payload)) + + assert state.alarm == "active" + + def test_read_server_error_retries_once_after_two_seconds( monkeypatch: pytest.MonkeyPatch, duepi_test_modules: SimpleNamespace ) -> None: diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index b4c29fa..f3b6f1d 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -168,3 +168,48 @@ def test_manual_device_maps_parse_failure( assert result["step_id"] == "manual_device" assert result["errors"] == {"base": "invalid_device"} + + +def test_options_reject_an_inverted_pellet_range( + duepi_test_modules: SimpleNamespace, +) -> None: + """The configured minimum pellet rate cannot exceed the maximum.""" + flow = duepi_test_modules.config_flow.DuepiOptionsFlow( + SimpleNamespace(options={}) + ) + options = { + "scan_interval": 30, + "default_power": 5, + "default_temperature": 25, + "eco_power": 1, + "eco_temperature": 18, + "pellet_kg_per_hour_min": 2.0, + "pellet_kg_per_hour_max": 1.0, + } + + result = asyncio.run(flow.async_step_init(options)) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_pellet_range"} + + +def test_options_accept_a_valid_pellet_range( + duepi_test_modules: SimpleNamespace, +) -> None: + """Valid preset and consumption options are stored unchanged.""" + flow = duepi_test_modules.config_flow.DuepiOptionsFlow( + SimpleNamespace(options={}) + ) + options = { + "scan_interval": 60, + "default_power": 4, + "default_temperature": 24, + "eco_power": 2, + "eco_temperature": 17, + "pellet_kg_per_hour_min": 0.7, + "pellet_kg_per_hour_max": 1.9, + } + + result = asyncio.run(flow.async_step_init(options)) + + assert result == {"type": "create_entry", "data": options} diff --git a/tests/test_connectivity.py b/tests/test_connectivity.py index 09d6904..48d6fdf 100644 --- a/tests/test_connectivity.py +++ b/tests/test_connectivity.py @@ -6,7 +6,7 @@ import importlib import logging from collections.abc import Iterator -from datetime import timedelta +from datetime import datetime, timedelta, timezone from types import SimpleNamespace import pytest @@ -332,6 +332,35 @@ def test_raw_online_restores_connectivity_immediately( assert [state.online for state in results] == [True, True, False, True, True] +def test_last_seen_updates_only_on_an_explicit_online_report( + monkeypatch: pytest.MonkeyPatch, duepi_test_modules: SimpleNamespace +) -> None: + """Offline and unknown reports preserve the last confirmed online time.""" + first_seen = datetime(2026, 7, 16, 8, 0, tzinfo=timezone.utc) + second_seen = datetime(2026, 7, 16, 8, 5, tzinfo=timezone.utc) + timestamps = iter([first_seen, second_seen]) + + class FixedDateTime: + @classmethod + def now(cls, tz: timezone) -> datetime: + assert tz is timezone.utc + return next(timestamps) + + monkeypatch.setattr(duepi_test_modules.coordinator, "datetime", FixedDateTime) + coordinator = duepi_test_modules.coordinator.DuepiCoordinator( + object(), config_entry(), FakeClient([]), timedelta(seconds=30) + ) + assert coordinator.last_seen is None + + coordinator._filter_connectivity(stove_state(duepi_test_modules.api, True)) + assert coordinator.last_seen == first_seen + coordinator._filter_connectivity(stove_state(duepi_test_modules.api, False)) + coordinator._filter_connectivity(stove_state(duepi_test_modules.api, None)) + assert coordinator.last_seen == first_seen + coordinator._filter_connectivity(stove_state(duepi_test_modules.api, True)) + assert coordinator.last_seen == second_seen + + def test_unknown_report_does_not_hide_a_later_reconnect( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, diff --git a/tests/test_integration_regressions.py b/tests/test_integration_regressions.py index 00a78b5..1d7a558 100644 --- a/tests/test_integration_regressions.py +++ b/tests/test_integration_regressions.py @@ -137,6 +137,8 @@ def create_session(hass: object, **kwargs: object) -> FakeSession: assert created["coordinator_kwargs"] == { "default_power": 4, "default_temperature": 27, + "eco_power": 1, + "eco_temperature": 18, "update_interval": timedelta(seconds=30), } @@ -156,12 +158,14 @@ def test_diagnostics_redacts_all_identifiers_and_exposes_connectivity_timing( set_temperature=27, raw_online=False, online=True, + alarm="A01", ), raw_online=False, filtered_online=True, disconnect_elapsed_seconds=0, last_update_success=True, last_successful_update_time="2026-07-13T10:00:00+00:00", + last_seen="2026-07-13T09:59:30+00:00", last_exception=RuntimeError("email=person@example.test token=secret"), ) @@ -175,6 +179,8 @@ def test_diagnostics_redacts_all_identifiers_and_exposes_connectivity_timing( "grace_period_seconds": 120, } assert result["last_successful_update"] == "2026-07-13T10:00:00+00:00" + assert result["last_seen"] == "2026-07-13T09:59:30+00:00" + assert result["state"]["alarm"] == "A01" assert result["last_error"] == "RuntimeError" assert "device-secret" not in serialized assert "person@example.test" not in serialized diff --git a/tests/test_presets.py b/tests/test_presets.py new file mode 100644 index 0000000..9a2de5b --- /dev/null +++ b/tests/test_presets.py @@ -0,0 +1,181 @@ +"""Coordinator tests for Duepi climate presets and problem detection.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from types import SimpleNamespace + +import pytest + + +class CommandClient: + """Record combined turn-on commands and optionally fail them.""" + + def __init__(self, *, fail: bool = False) -> None: + self.calls: list[tuple[int, int]] = [] + self.fail = fail + + async def async_turn_on(self, *, power: int, temperature: int) -> None: + self.calls.append((power, temperature)) + if self.fail: + raise RuntimeError("command failed") + + +def _coordinator( + duepi: SimpleNamespace, + client: CommandClient | None = None, + *, + default_power: int = 5, + default_temperature: int = 25, + eco_power: int = 1, + eco_temperature: int = 18, +) -> object: + """Build a coordinator with deterministic preset options.""" + return duepi.coordinator.DuepiCoordinator( + object(), + SimpleNamespace(async_create_background_task=lambda *_args, **_kwargs: None), + client or CommandClient(), + timedelta(seconds=30), + default_power=default_power, + default_temperature=default_temperature, + eco_power=eco_power, + eco_temperature=eco_temperature, + ) + + +def _state( + duepi: SimpleNamespace, + *, + power_on: bool, + power: int = 3, + temperature: int = 22, + alarm: str | None = None, + status: str | None = "Heating", +) -> object: + """Build a complete stove state.""" + return duepi.api.DuepiStoveState( + power_on=power_on, + status_text=status, + room_temperature=20.0, + working_power=power, + set_temperature=temperature, + raw_online=True, + online=True, + alarm=alarm, + ) + + +def test_preset_targets_and_current_match(duepi_test_modules: SimpleNamespace) -> None: + """Eco, comfort, and unmatched states map to stable preset names.""" + coordinator = _coordinator(duepi_test_modules) + assert coordinator.preset_targets("eco") == (1, 18) + assert coordinator.preset_targets("comfort") == (5, 25) + assert coordinator.preset_targets("none") is None + + coordinator.async_set_updated_data( + _state(duepi_test_modules, power_on=True, power=1, temperature=18) + ) + assert coordinator.current_preset() == "eco" + coordinator.async_set_updated_data( + _state(duepi_test_modules, power_on=True, power=5, temperature=25) + ) + assert coordinator.current_preset() == "comfort" + coordinator.async_set_updated_data(_state(duepi_test_modules, power_on=True)) + assert coordinator.current_preset() == "none" + + with pytest.raises(ValueError, match="Unsupported preset"): + coordinator.preset_targets("holiday") + + +def test_eco_wins_when_preset_targets_collide( + duepi_test_modules: SimpleNamespace, +) -> None: + """A target matching both configured pairs is presented as eco.""" + coordinator = _coordinator( + duepi_test_modules, + default_power=1, + default_temperature=18, + ) + coordinator.async_set_updated_data( + _state(duepi_test_modules, power_on=True, power=1, temperature=18) + ) + assert coordinator.current_preset() == "eco" + + +def test_active_preset_uses_one_combined_command_and_preserves_alarm( + duepi_test_modules: SimpleNamespace, +) -> None: + """Optimistic preset updates preserve every unrelated state field.""" + client = CommandClient() + coordinator = _coordinator(duepi_test_modules, client) + coordinator.async_set_updated_data( + _state(duepi_test_modules, power_on=True, alarm="A01") + ) + + asyncio.run(coordinator.async_set_preset("eco")) + + assert client.calls == [(1, 18)] + assert coordinator.data.working_power == 1 + assert coordinator.data.set_temperature == 18 + assert coordinator.data.alarm == "A01" + + +def test_off_preset_is_consumed_only_after_successful_turn_on( + duepi_test_modules: SimpleNamespace, +) -> None: + """A queued preset survives a failed command and is one-shot on success.""" + failing_client = CommandClient(fail=True) + coordinator = _coordinator(duepi_test_modules, failing_client) + coordinator.async_set_updated_data(_state(duepi_test_modules, power_on=False)) + asyncio.run(coordinator.async_set_preset("comfort")) + assert coordinator.current_preset() == "comfort" + + with pytest.raises(RuntimeError, match="command failed"): + asyncio.run(coordinator.async_turn_on()) + assert coordinator.current_preset() == "comfort" + + succeeding_client = CommandClient() + coordinator.client = succeeding_client + asyncio.run(coordinator.async_turn_on()) + assert succeeding_client.calls == [(5, 25)] + assert coordinator._pending_preset is None + + +def test_none_clears_a_queued_preset(duepi_test_modules: SimpleNamespace) -> None: + """The explicit none mode cancels pending preset behavior without a command.""" + client = CommandClient() + coordinator = _coordinator(duepi_test_modules, client) + coordinator.async_set_updated_data(_state(duepi_test_modules, power_on=False)) + + asyncio.run(coordinator.async_set_preset("eco")) + asyncio.run(coordinator.async_set_preset("none")) + + assert coordinator._pending_preset is None + assert client.calls == [] + + +@pytest.mark.parametrize( + ("alarm", "status", "expected"), + [ + ("A01", "Heating", True), + (None, "Error: fan", True), + (None, "ALARM active", True), + (None, "Heating", False), + ], +) +def test_problem_detection_uses_alarm_and_status_fallback( + duepi_test_modules: SimpleNamespace, + alarm: str | None, + status: str, + expected: bool, +) -> None: + """Problem state remains useful when speculative alarm fields are absent.""" + state = _state( + duepi_test_modules, + power_on=True, + alarm=alarm, + status=status, + ) + assert duepi_test_modules.coordinator.state_has_problem(state) is expected + assert duepi_test_modules.coordinator.state_has_problem(None) is None From fb31f67304482ee4c68de4c5529d22a8e772aa13 Mon Sep 17 00:00:00 2001 From: Florian Casse Date: Thu, 16 Jul 2026 09:12:31 +0200 Subject: [PATCH 3/6] Prepare HACS compliant v1.3 release --- .github/workflows/ci.yml | 14 +++ .github/workflows/release.yml | 56 ++++++++++ README.md | 51 ++++----- custom_components/duepi/brand/icon.png | Bin 0 -> 8681 bytes custom_components/duepi/brand/icon.svg | 14 +++ custom_components/duepi/manifest.json | 10 +- custom_components/duepi/strings.json | 43 +++++++- custom_components/duepi/translations/en.json | 109 +++++++++++++++++++ custom_components/duepi/translations/fr.json | 43 +++++++- hacs.json | 4 +- tasks/todo.md | 2 +- tests/test_metadata.py | 64 +++++++++++ 12 files changed, 368 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 custom_components/duepi/brand/icon.png create mode 100644 custom_components/duepi/brand/icon.svg create mode 100644 custom_components/duepi/translations/en.json create mode 100644 tests/test_metadata.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22bd12f..9a72be6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,17 @@ jobs: run: ruff check . - name: Test run: pytest -q + + hassfest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: home-assistant/actions/hassfest@master + + hacs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: hacs/action@main + with: + category: integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bd406f4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify tag matches manifest version + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + python3 - <<'PY' + import json + import os + from pathlib import Path + + tag_version = os.environ["RELEASE_TAG"].removeprefix("v") + manifest = json.loads( + Path("custom_components/duepi/manifest.json").read_text() + ) + manifest_version = manifest["version"] + if tag_version != manifest_version: + raise SystemExit( + f"Tag version {tag_version} does not match manifest {manifest_version}" + ) + PY + + - name: Build HACS release archive + working-directory: custom_components/duepi + run: | + zip -r "$GITHUB_WORKSPACE/duepi.zip" . \ + -x "__pycache__/*" "__pycache__/" \ + "*/__pycache__/*" "*/__pycache__/" "*.pyc" + + - name: Verify archive layout + run: | + unzip -Z1 duepi.zip | grep -qx "manifest.json" + if unzip -Z1 duepi.zip | grep -q '^custom_components/'; then + echo "Archive must contain integration files at its root" >&2 + exit 1 + fi + + - name: Publish GitHub release + uses: softprops/action-gh-release@v3 + with: + files: duepi.zip + fail_on_unmatched_files: true diff --git a/README.md b/README.md index c93ff35..a0fe978 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ Control a Duepi pellet stove from Home Assistant via the [dpremoteiot.com](https - **Native Climate entity** — full HVAC control with temperature and power level, using the stove's built-in temperature sensor - **Auto-login** — authenticates with email/password, auto-renews the session when it expires (no more manual cookie extraction!) +- **Automatic stove discovery** — sign in and select a stove without hunting for its device ID - **Config flow UI** — set up entirely from the Home Assistant UI (Settings → Add Integration) -- **Rich entities** — climate, temperature sensor, power level, status text, online/offline connectivity, power slider +- **Eco and Comfort presets** — switch temperature and power together from the climate entity +- **Rich diagnostics** — alarms, problem state, last-seen time, operating hours, and estimated pellet use - **HACS compatible** — install easily via HACS custom repository - **Reauth flow** — automatically prompts for new credentials if authentication fails - **Diagnostics** — built-in diagnostic dump for troubleshooting @@ -19,8 +21,6 @@ Control a Duepi pellet stove from Home Assistant via the [dpremoteiot.com](https - An account on [dpremoteiot.com](https://dpremoteiot.com) with your stove added. - Home Assistant 2024.6.0 or later. -![Account and Device ID](screenshots/AccountAndDeviceID.png) - ## Installation ### Via HACS (recommended) @@ -62,35 +62,20 @@ Use this method if you don't want to use HACS. Once the integration is installed and HA has restarted: -### Step 1 — Find your Device ID - -Before adding the integration, you need your **Device ID** from dpremoteiot.com: - -1. Log in to [dpremoteiot.com](https://dpremoteiot.com). -2. On the dashboard, find your stove and look for the **delete device** button. -3. Inspect the button's link — it contains `deviceid=YOUR_DEVICE_ID`. - -![Account and Device ID](screenshots/AccountAndDeviceID.png) - -### Step 2 — Add the integration +### Step 1 — Add the integration 1. Go to **Settings → Devices & Services**. 2. Click **+ Add Integration** (bottom-right). 3. Search for **Duepi Pellet Stove** and select it. -4. Fill in the form: - -| Field | Description | -|---|---| -| **Email** | Your dpremoteiot.com account email | -| **Password** | Your dpremoteiot.com account password | -| **Device ID** | The device ID you found in Step 1 | +4. Enter your dpremoteiot.com email and password. +5. Select the stove discovered on your account. Selection is shown even when the account contains only one stove. +6. Click **Submit**. A new **Duepi Pellet Stove** device appears with all its entities. -5. Click **Submit**. The integration will validate your credentials by logging in to dpremoteiot.com. -6. If successful, a new **Duepi Pellet Stove** device appears with all its entities. +If the dashboard format prevents automatic discovery, the flow falls back to a manual **Device ID** field. Existing installations keep their current device ID and do not need to be reconfigured. > **Authentication failed?** Double-check your email and password. Make sure you can log in to [dpremoteiot.com](https://dpremoteiot.com) with the same credentials in a browser. -### Step 3 — Configure options (optional) +### Step 2 — Configure options (optional) 1. Go to **Settings → Devices & Services → Duepi Pellet Stove**. 2. Click **Configure**. @@ -98,8 +83,14 @@ Before adding the integration, you need your **Device ID** from dpremoteiot.com: | Option | Default | Description | |---|---|---| | Update interval | 30s | How often to poll the stove state (min: 30s) | -| Default power | 5 | Power level used when turning on (1-5) | -| Default temperature | 25°C | Temperature used when turning on (5-40°C) | +| Comfort/default power | 5 | Power level used for Comfort and normal turn-on (1-5) | +| Comfort/default temperature | 25°C | Temperature used for Comfort and normal turn-on (5-40°C) | +| Eco power | 1 | Power level used by the Eco preset (1-5) | +| Eco temperature | 18°C | Temperature used by the Eco preset (5-40°C) | +| Pellet rate at power 1 | 0.6 kg/h | Estimated minimum consumption rate (0-10 kg/h) | +| Pellet rate at power 5 | 1.8 kg/h | Estimated maximum consumption rate (0-10 kg/h) | + +The minimum pellet rate must not exceed the maximum. Changing any option reloads the integration automatically. ## Entities @@ -107,12 +98,17 @@ All entities are grouped under a single **Duepi Pellet Stove** device: | Entity | Type | Description | |---|---|---| -| Duepi Pellet Stove | Climate | Main control — HVAC mode (heat/off), target temperature, fan mode (power 1-5) | +| Duepi Pellet Stove | Climate | Main control — HVAC mode, target temperature, power 1-5, and Eco/Comfort presets | | Room temperature | Sensor | Current room temperature from the stove's built-in sensor (°C) | | Power level | Sensor | Current working power level (1-5) | | Status | Sensor | Status text (Heating, Idle, Standby, Off...) | | Set temperature | Sensor | Current target temperature (°C) | | Online | Binary sensor | Whether the stove is reachable via dpremoteiot.com | +| Problem | Binary sensor | Active when an alarm field or alarm/error status is reported | +| Alarm | Sensor | Cloud alarm text/code, disabled by default because fields vary by stove | +| Last seen | Sensor | Last UTC time the cloud explicitly reported the stove online | +| Operating hours | Sensor | Restored cumulative time while the stove is on | +| Pellet consumption (estimated) | Sensor | Restored consumption estimate based on runtime and power, disabled by default | | Power level | Number | Slider to adjust power level (1-5) | The cloud can briefly report a healthy stove offline during its periodic heartbeat. @@ -287,3 +283,4 @@ Restart Home Assistant and view the logs: - **Power level may be ignored on startup** — some stove models always start at a fixed power level regardless of the `settedPower` value. This is a stove firmware limitation, not a bug in this integration. - **Cloud dependency** — requires internet connectivity (communicates via dpremoteiot.com, not locally). - **HTML scraping fallback** — state detection primarily uses JSON data embedded in the dashboard HTML, with regex parsing as a fallback. If dpremoteiot.com changes their layout, the integration may need updating. +- **Estimated pellet use** — consumption is calculated from configured rates and observed power, not measured by the stove. Gaps are capped to avoid counting through long outages or restarts. diff --git a/custom_components/duepi/brand/icon.png b/custom_components/duepi/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fd54ac13af7b312869b0f3d3a2ca24b566ba5c24 GIT binary patch literal 8681 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 KS|w*5{C@y(@z72H literal 0 HcmV?d00001 diff --git a/custom_components/duepi/brand/icon.svg b/custom_components/duepi/brand/icon.svg new file mode 100644 index 0000000..dea7eb3 --- /dev/null +++ b/custom_components/duepi/brand/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/custom_components/duepi/manifest.json b/custom_components/duepi/manifest.json index 562ffd1..96f4043 100644 --- a/custom_components/duepi/manifest.json +++ b/custom_components/duepi/manifest.json @@ -1,13 +1,13 @@ { "domain": "duepi", "name": "Duepi Pellet Stove", - "version": "1.2.1", + "codeowners": ["@FlorianCasse"], "config_flow": true, + "dependencies": [], "documentation": "https://github.com/FlorianCasse/DuepiRemoteHA", + "integration_type": "device", + "iot_class": "cloud_polling", "issue_tracker": "https://github.com/FlorianCasse/DuepiRemoteHA/issues", "requirements": [], - "dependencies": [], - "codeowners": ["@FlorianCasse"], - "iot_class": "cloud_polling", - "integration_type": "device" + "version": "1.3.0" } diff --git a/custom_components/duepi/strings.json b/custom_components/duepi/strings.json index 3444995..a95c1b2 100644 --- a/custom_components/duepi/strings.json +++ b/custom_components/duepi/strings.json @@ -4,10 +4,23 @@ "step": { "user": { "title": "Connect to dpremoteiot.com", - "description": "Enter your dpremoteiot.com account credentials and device ID.", + "description": "Enter your dpremoteiot.com account credentials. Your stoves will be discovered automatically.", "data": { "email": "Email", - "password": "Password", + "password": "Password" + } + }, + "select_device": { + "title": "Select a stove", + "description": "Choose the stove to add to Home Assistant.", + "data": { + "device_id": "Stove" + } + }, + "manual_device": { + "title": "Enter the device ID", + "description": "No stove could be discovered automatically. Enter its dpremoteiot.com device ID.", + "data": { "device_id": "Device ID" } }, @@ -39,10 +52,17 @@ "title": "Settings", "data": { "scan_interval": "Update interval (seconds)", - "default_power": "Default power level (1-5)", - "default_temperature": "Default temperature (°C)" + "default_power": "Comfort/default power level (1-5)", + "default_temperature": "Comfort/default temperature (°C)", + "eco_power": "Eco power level (1-5)", + "eco_temperature": "Eco temperature (°C)", + "pellet_kg_per_hour_min": "Estimated pellet rate at power 1 (kg/h)", + "pellet_kg_per_hour_max": "Estimated pellet rate at power 5 (kg/h)" } } + }, + "error": { + "invalid_pellet_range": "The minimum pellet rate cannot be greater than the maximum rate." } }, "entity": { @@ -58,11 +78,26 @@ }, "set_temperature": { "name": "Set temperature" + }, + "alarm": { + "name": "Alarm" + }, + "last_seen": { + "name": "Last seen" + }, + "operating_hours": { + "name": "Operating hours" + }, + "pellet_consumption": { + "name": "Pellet consumption (estimated)" } }, "binary_sensor": { "online": { "name": "Online" + }, + "problem": { + "name": "Problem" } }, "number": { diff --git a/custom_components/duepi/translations/en.json b/custom_components/duepi/translations/en.json new file mode 100644 index 0000000..a95c1b2 --- /dev/null +++ b/custom_components/duepi/translations/en.json @@ -0,0 +1,109 @@ +{ + "title": "Duepi Pellet Stove", + "config": { + "step": { + "user": { + "title": "Connect to dpremoteiot.com", + "description": "Enter your dpremoteiot.com account credentials. Your stoves will be discovered automatically.", + "data": { + "email": "Email", + "password": "Password" + } + }, + "select_device": { + "title": "Select a stove", + "description": "Choose the stove to add to Home Assistant.", + "data": { + "device_id": "Stove" + } + }, + "manual_device": { + "title": "Enter the device ID", + "description": "No stove could be discovered automatically. Enter its dpremoteiot.com device ID.", + "data": { + "device_id": "Device ID" + } + }, + "reauth_confirm": { + "title": "Re-authenticate", + "description": "Your session has expired. Please re-enter your credentials.", + "data": { + "email": "Email", + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid email or password.", + "invalid_device": "The configured device was not found or returned invalid data.", + "cannot_connect": "Cannot connect to dpremoteiot.com.", + "server_error": "The dpremoteiot.com service is temporarily unavailable.", + "rate_limited": "Too many requests to dpremoteiot.com. Please try again later.", + "unknown": "An unexpected error occurred." + }, + "abort": { + "already_configured": "This device is already configured.", + "reauth_successful": "Re-authentication successful." + } + }, + "options": { + "step": { + "init": { + "title": "Settings", + "data": { + "scan_interval": "Update interval (seconds)", + "default_power": "Comfort/default power level (1-5)", + "default_temperature": "Comfort/default temperature (°C)", + "eco_power": "Eco power level (1-5)", + "eco_temperature": "Eco temperature (°C)", + "pellet_kg_per_hour_min": "Estimated pellet rate at power 1 (kg/h)", + "pellet_kg_per_hour_max": "Estimated pellet rate at power 5 (kg/h)" + } + } + }, + "error": { + "invalid_pellet_range": "The minimum pellet rate cannot be greater than the maximum rate." + } + }, + "entity": { + "sensor": { + "room_temperature": { + "name": "Room temperature" + }, + "power_level": { + "name": "Power level" + }, + "status": { + "name": "Status" + }, + "set_temperature": { + "name": "Set temperature" + }, + "alarm": { + "name": "Alarm" + }, + "last_seen": { + "name": "Last seen" + }, + "operating_hours": { + "name": "Operating hours" + }, + "pellet_consumption": { + "name": "Pellet consumption (estimated)" + } + }, + "binary_sensor": { + "online": { + "name": "Online" + }, + "problem": { + "name": "Problem" + } + }, + "number": { + "power_level": { + "name": "Power level" + } + } + } +} diff --git a/custom_components/duepi/translations/fr.json b/custom_components/duepi/translations/fr.json index 81b7573..924cb43 100644 --- a/custom_components/duepi/translations/fr.json +++ b/custom_components/duepi/translations/fr.json @@ -4,10 +4,23 @@ "step": { "user": { "title": "Connexion à dpremoteiot.com", - "description": "Entrez vos identifiants dpremoteiot.com et l'ID de votre appareil.", + "description": "Entrez vos identifiants dpremoteiot.com. Vos poêles seront découverts automatiquement.", "data": { "email": "Email", - "password": "Mot de passe", + "password": "Mot de passe" + } + }, + "select_device": { + "title": "Sélectionner un poêle", + "description": "Choisissez le poêle à ajouter à Home Assistant.", + "data": { + "device_id": "Poêle" + } + }, + "manual_device": { + "title": "Saisir l'ID de l'appareil", + "description": "Aucun poêle n'a pu être découvert automatiquement. Saisissez son ID d'appareil dpremoteiot.com.", + "data": { "device_id": "ID de l'appareil" } }, @@ -39,10 +52,17 @@ "title": "Paramètres", "data": { "scan_interval": "Intervalle de mise à jour (secondes)", - "default_power": "Puissance par défaut (1-5)", - "default_temperature": "Température par défaut (°C)" + "default_power": "Puissance Confort/par défaut (1-5)", + "default_temperature": "Température Confort/par défaut (°C)", + "eco_power": "Puissance Eco (1-5)", + "eco_temperature": "Température Eco (°C)", + "pellet_kg_per_hour_min": "Débit estimé à la puissance 1 (kg/h)", + "pellet_kg_per_hour_max": "Débit estimé à la puissance 5 (kg/h)" } } + }, + "error": { + "invalid_pellet_range": "Le débit minimal de pellets ne peut pas dépasser le débit maximal." } }, "entity": { @@ -58,11 +78,26 @@ }, "set_temperature": { "name": "Température de consigne" + }, + "alarm": { + "name": "Alarme" + }, + "last_seen": { + "name": "Dernière connexion" + }, + "operating_hours": { + "name": "Heures de fonctionnement" + }, + "pellet_consumption": { + "name": "Consommation de pellets (estimée)" } }, "binary_sensor": { "online": { "name": "En ligne" + }, + "problem": { + "name": "Problème" } }, "number": { diff --git a/hacs.json b/hacs.json index 4ea4039..902a93b 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,8 @@ { "name": "Duepi Pellet Stove", "content_in_root": false, + "filename": "duepi.zip", + "homeassistant": "2024.6.0", "render_readme": true, - "homeassistant": "2024.6.0" + "zip_release": true } diff --git a/tasks/todo.md b/tasks/todo.md index b8ca891..6bc712b 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -2,5 +2,5 @@ - [x] Workstream A: automatic device discovery - [x] Workstream B: alarms, presets, last seen, and accumulating sensors -- [ ] Workstream C: translations, HACS compliance, release workflow, and README +- [x] Workstream C: translations, HACS compliance, release workflow, and README - [ ] Push the branch and verify GitHub Actions diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 0000000..f829d90 --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,64 @@ +"""Metadata, translation, branding, and release regression tests.""" + +from __future__ import annotations + +import json +from pathlib import Path +import struct + + +ROOT = Path(__file__).parents[1] +INTEGRATION = ROOT / "custom_components" / "duepi" + + +def _load_json(path: Path) -> dict[str, object]: + """Load a repository JSON document.""" + return json.loads(path.read_text()) + + +def _structure(value: object) -> object: + """Return nested translation keys while ignoring translated values.""" + if isinstance(value, dict): + return {key: _structure(child) for key, child in value.items()} + return type(value) + + +def test_english_translation_exactly_matches_source_strings() -> None: + """Custom integrations need an explicit runtime English translation.""" + strings = (INTEGRATION / "strings.json").read_text() + english = (INTEGRATION / "translations" / "en.json").read_text() + assert english == strings + + +def test_french_translation_has_the_same_key_structure() -> None: + """French covers every form, error, option, and entity translation key.""" + source = _load_json(INTEGRATION / "strings.json") + french = _load_json(INTEGRATION / "translations" / "fr.json") + assert _structure(french) == _structure(source) + + +def test_release_metadata_is_consistent() -> None: + """Manifest and HACS metadata agree on the v1.3 release contract.""" + manifest = _load_json(INTEGRATION / "manifest.json") + hacs = _load_json(ROOT / "hacs.json") + assert manifest["version"] == "1.3.0" + assert hacs["zip_release"] is True + assert hacs["filename"] == "duepi.zip" + + +def test_brand_icon_is_a_transparent_256_pixel_png() -> None: + """The local brand asset satisfies HACS without a third-party logo.""" + data = (INTEGRATION / "brand" / "icon.png").read_bytes() + assert data[:8] == b"\x89PNG\r\n\x1a\n" + width, height = struct.unpack(">II", data[16:24]) + assert (width, height) == (256, 256) + assert data[25] == 6 # PNG color type RGBA + + +def test_release_workflow_builds_a_rooted_hacs_archive() -> None: + """Release automation uses the supported action and root archive layout.""" + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text() + assert "working-directory: custom_components/duepi" in workflow + assert "softprops/action-gh-release@v3" in workflow + assert "grep -qx \"manifest.json\"" in workflow + assert "grep -q '^custom_components/'" in workflow From ec05c77c332fdeaa6b314dd0a5c46d1a03146a01 Mon Sep 17 00:00:00 2001 From: Florian Casse Date: Thu, 16 Jul 2026 09:16:45 +0200 Subject: [PATCH 4/6] Add MIT license for HACS --- LICENSE | 21 +++++++++++++++++++++ tasks/todo.md | 2 +- tests/test_metadata.py | 7 +++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3c4834f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Florian Casse + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tasks/todo.md b/tasks/todo.md index 6bc712b..c1586a5 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -3,4 +3,4 @@ - [x] Workstream A: automatic device discovery - [x] Workstream B: alarms, presets, last seen, and accumulating sensors - [x] Workstream C: translations, HACS compliance, release workflow, and README -- [ ] Push the branch and verify GitHub Actions +- [x] Push the branch and verify GitHub Actions diff --git a/tests/test_metadata.py b/tests/test_metadata.py index f829d90..892cafc 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -46,6 +46,13 @@ def test_release_metadata_is_consistent() -> None: assert hacs["filename"] == "duepi.zip" +def test_repository_declares_the_mit_license() -> None: + """HACS requires an explicit repository license.""" + license_text = (ROOT / "LICENSE").read_text() + assert license_text.startswith("MIT License") + assert "Copyright (c) 2026 Florian Casse" in license_text + + def test_brand_icon_is_a_transparent_256_pixel_png() -> None: """The local brand asset satisfies HACS without a third-party logo.""" data = (INTEGRATION / "brand" / "icon.png").read_bytes() From c4e19289758ecfa9864236aaab4f36375375a082 Mon Sep 17 00:00:00 2001 From: Florian Casse Date: Thu, 16 Jul 2026 09:21:46 +0200 Subject: [PATCH 5/6] Handle HACS license checks on feature branches --- .github/workflows/ci.yml | 3 +++ tests/test_metadata.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a72be6..6e0ee94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,6 @@ jobs: - uses: hacs/action@main with: category: integration + # GitHub only detects a repository license from the default branch. + # Keep feature branches testable until their LICENSE reaches main. + ignore: ${{ github.ref_name != github.event.repository.default_branch && 'license' || '' }} diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 892cafc..296f67a 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -53,6 +53,15 @@ def test_repository_declares_the_mit_license() -> None: assert "Copyright (c) 2026 Florian Casse" in license_text +def test_hacs_ci_keeps_default_branch_validation_strict() -> None: + """Only feature branches bypass GitHub's default-branch license cache.""" + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text() + assert "uses: hacs/action@main" in workflow + assert "github.ref_name != github.event.repository.default_branch" in workflow + assert "'license' || ''" in workflow + assert "ignore: brands" not in workflow + + def test_brand_icon_is_a_transparent_256_pixel_png() -> None: """The local brand asset satisfies HACS without a third-party logo.""" data = (INTEGRATION / "brand" / "icon.png").read_bytes() From 308fd40e323fbf6b87b8c21e2cbc8adf455a7606 Mon Sep 17 00:00:00 2001 From: Florian Casse Date: Thu, 16 Jul 2026 09:34:25 +0200 Subject: [PATCH 6/6] Automate release notes and display branding --- .github/workflows/release.yml | 1 + README.md | 4 ++++ tasks/todo.md | 1 + tests/test_metadata.py | 8 ++++++++ 4 files changed, 14 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd406f4..95a37f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,3 +54,4 @@ jobs: with: files: duepi.zip fail_on_unmatched_files: true + generate_release_notes: true diff --git a/README.md b/README.md index a0fe978..ae9a30b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # DuepiRemoteHA +

+ Duepi pellet stove icon +

+ Control a Duepi pellet stove from Home Assistant via the [dpremoteiot.com](https://dpremoteiot.com) cloud API. ## Features diff --git a/tasks/todo.md b/tasks/todo.md index c1586a5..bf4421f 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -4,3 +4,4 @@ - [x] Workstream B: alarms, presets, last seen, and accumulating sensors - [x] Workstream C: translations, HACS compliance, release workflow, and README - [x] Push the branch and verify GitHub Actions +- [x] Generate release notes automatically and display the original brand icon diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 296f67a..6038060 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -76,5 +76,13 @@ def test_release_workflow_builds_a_rooted_hacs_archive() -> None: workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text() assert "working-directory: custom_components/duepi" in workflow assert "softprops/action-gh-release@v3" in workflow + assert "generate_release_notes: true" in workflow assert "grep -qx \"manifest.json\"" in workflow assert "grep -q '^custom_components/'" in workflow + + +def test_readme_displays_the_local_brand_icon() -> None: + """Repository visitors can see the original integration branding.""" + readme = (ROOT / "README.md").read_text() + assert 'src="custom_components/duepi/brand/icon.png"' in readme + assert 'alt="Duepi pellet stove icon"' in readme