From 6645941778fa25f9f1f464ec2d8377491f0f10ac Mon Sep 17 00:00:00 2001 From: sguernion Date: Sun, 5 Jul 2026 20:18:39 +0200 Subject: [PATCH] refactor: extract API client, add typed models, and optimise polling --- custom_components/open_firenet/__init__.py | 3 +- custom_components/open_firenet/api.py | 91 +++++++++++++++++++ .../open_firenet/binary_sensor.py | 2 +- custom_components/open_firenet/climate.py | 17 ++-- custom_components/open_firenet/config_flow.py | 11 +-- custom_components/open_firenet/coordinator.py | 43 ++------- custom_components/open_firenet/sensor.py | 5 +- 7 files changed, 118 insertions(+), 54 deletions(-) create mode 100644 custom_components/open_firenet/api.py diff --git a/custom_components/open_firenet/__init__.py b/custom_components/open_firenet/__init__.py index 775a4a7..1e2cbbf 100644 --- a/custom_components/open_firenet/__init__.py +++ b/custom_components/open_firenet/__init__.py @@ -25,6 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + coordinator: OpenFirenetCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator._client.close() return True return False diff --git a/custom_components/open_firenet/api.py b/custom_components/open_firenet/api.py new file mode 100644 index 0000000..7711826 --- /dev/null +++ b/custom_components/open_firenet/api.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any + +import aiohttp + +from .const import API_CONTROLS, API_SENSORS, API_STATUS + + +@dataclass +class FirenetControls: + on_off: int + operating_mode: int + heating_power: int + temp_room_target: int + + @classmethod + def from_dict(cls, data: dict) -> FirenetControls: + return cls( + on_off=int(data.get("onOff", 0)), + operating_mode=int(data.get("operatingMode", 2)), + heating_power=int(data.get("heatingPower", 30)), + temp_room_target=int(data.get("tempRoomTarget", 200)), + ) + + def as_post_body(self) -> str: + return ( + f"onOff={self.on_off}; " + f"operatingMode={self.operating_mode}; " + f"heatingPower={self.heating_power}; " + f"tempRoomTarget={self.temp_room_target};" + ) + + def replace(self, **kwargs) -> FirenetControls: + return FirenetControls( + on_off=kwargs.get("onOff", self.on_off), + operating_mode=kwargs.get("operatingMode", self.operating_mode), + heating_power=kwargs.get("heatingPower", self.heating_power), + temp_room_target=kwargs.get("tempRoomTarget", self.temp_room_target), + ) + + +@dataclass +class FirenetData: + sensors: dict[str, Any] + controls: FirenetControls + + +class OpenFirenetClient: + def __init__(self, host: str) -> None: + self._base = f"http://{host}" + self._session: aiohttp.ClientSession | None = None + + def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + async def async_validate(self) -> bool: + data = await self._get(API_STATUS) + return "mainLoop" in data + + async def fetch_all(self) -> FirenetData: + sensors_raw, controls_raw = await asyncio.gather( + self._get(API_SENSORS), + self._get(API_CONTROLS), + ) + return FirenetData( + sensors=sensors_raw, + controls=FirenetControls.from_dict(controls_raw), + ) + + async def set_controls(self, controls: FirenetControls) -> None: + async with self._get_session().post( + f"{self._base}{API_CONTROLS}", + data=controls.as_post_body(), + headers={"Content-Type": "text/plain"}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + resp.raise_for_status() + + async def _get(self, path: str) -> dict: + async with self._get_session().get(f"{self._base}{path}") as resp: + resp.raise_for_status() + return await resp.json() diff --git a/custom_components/open_firenet/binary_sensor.py b/custom_components/open_firenet/binary_sensor.py index 825c5ab..74ec812 100644 --- a/custom_components/open_firenet/binary_sensor.py +++ b/custom_components/open_firenet/binary_sensor.py @@ -34,4 +34,4 @@ def __init__(self, coordinator: OpenFirenetCoordinator, entry: ConfigEntry) -> N @property def is_on(self) -> bool: - return self.coordinator.data.get("status", {}).get("mainLoop", False) + return self.coordinator.last_update_success diff --git a/custom_components/open_firenet/climate.py b/custom_components/open_firenet/climate.py index 7ef0d4b..69af547 100644 --- a/custom_components/open_firenet/climate.py +++ b/custom_components/open_firenet/climate.py @@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .api import FirenetControls from .const import ( DOMAIN, HEATING_POWER_MAX, @@ -65,12 +66,12 @@ def __init__(self, coordinator: OpenFirenetCoordinator, entry: ConfigEntry) -> N } @property - def _controls(self) -> dict: - return self.coordinator.data["controls"] + def _controls(self) -> FirenetControls: + return self.coordinator.data.controls @property def current_temperature(self) -> float | None: - sensors = self.coordinator.data.get("sensors", {}) + sensors = self.coordinator.data.sensors for key in ROOM_TEMP_KEYS: raw = sensors.get(key) if raw is not None: @@ -82,21 +83,19 @@ def current_temperature(self) -> float | None: @property def hvac_mode(self) -> HVACMode: - return HVACMode.HEAT if self._controls.get("onOff", 0) == 1 else HVACMode.OFF + return HVACMode.HEAT if self._controls.on_off == 1 else HVACMode.OFF @property def preset_mode(self) -> str | None: - return OPERATING_MODES.get(self._controls.get("operatingMode", 2)) + return OPERATING_MODES.get(self._controls.operating_mode) @property def target_temperature(self) -> float | None: - raw = self._controls.get("tempRoomTarget") - return raw / 10 if raw is not None else None + return self._controls.temp_room_target / 10 @property def fan_mode(self) -> str | None: - power = self._controls.get("heatingPower") - return str(power) if power is not None else None + return str(self._controls.heating_power) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: await self.coordinator.async_set_controls(onOff=1 if hvac_mode == HVACMode.HEAT else 0) diff --git a/custom_components/open_firenet/config_flow.py b/custom_components/open_firenet/config_flow.py index daef16f..8f3edeb 100644 --- a/custom_components/open_firenet/config_flow.py +++ b/custom_components/open_firenet/config_flow.py @@ -8,7 +8,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL -from .const import API_STATUS, DEFAULT_SCAN_INTERVAL, DOMAIN +from .api import OpenFirenetClient +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -32,12 +33,8 @@ async def async_step_user(self, user_input=None) -> ConfigFlowResult: host = user_input[CONF_HOST].strip().rstrip("/") try: async with asyncio.timeout(8): - async with aiohttp.ClientSession() as session: - async with session.get(f"http://{host}{API_STATUS}") as resp: - resp.raise_for_status() - data = await resp.json() - if "mainLoop" not in data: - errors["base"] = "invalid_response" + if not await OpenFirenetClient(host).async_validate(): + errors["base"] = "invalid_response" except asyncio.TimeoutError: errors["base"] = "cannot_connect" except aiohttp.ClientError: diff --git a/custom_components/open_firenet/coordinator.py b/custom_components/open_firenet/coordinator.py index 286985e..1759e26 100644 --- a/custom_components/open_firenet/coordinator.py +++ b/custom_components/open_firenet/coordinator.py @@ -2,21 +2,23 @@ import asyncio import logging +from dataclasses import replace from datetime import timedelta import aiohttp from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_CONTROLS, API_SENSORS, API_STATUS, DOMAIN +from .api import FirenetData, OpenFirenetClient +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class OpenFirenetCoordinator(DataUpdateCoordinator): +class OpenFirenetCoordinator(DataUpdateCoordinator[FirenetData]): def __init__(self, hass: HomeAssistant, host: str, scan_interval: int) -> None: self.host = host - self._base = f"http://{host}" + self._client = OpenFirenetClient(host) super().__init__( hass, _LOGGER, @@ -24,43 +26,18 @@ def __init__(self, hass: HomeAssistant, host: str, scan_interval: int) -> None: update_interval=timedelta(seconds=scan_interval), ) - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> FirenetData: try: async with asyncio.timeout(10): - async with aiohttp.ClientSession() as session: - status, sensors, controls = await asyncio.gather( - self._get(session, API_STATUS), - self._get(session, API_SENSORS), - self._get(session, API_CONTROLS), - ) - return {"status": status, "sensors": sensors, "controls": controls} + return await self._client.fetch_all() except asyncio.TimeoutError as err: raise UpdateFailed(f"Timeout connecting to {self.host}") from err except aiohttp.ClientError as err: raise UpdateFailed(f"Error communicating with {self.host}: {err}") from err - async def _get(self, session: aiohttp.ClientSession, path: str) -> dict: - async with session.get(f"{self._base}{path}") as resp: - resp.raise_for_status() - return await resp.json() - async def async_set_controls(self, **kwargs) -> None: - controls = self.data["controls"].copy() - controls.update(kwargs) - body = ( - f"onOff={controls['onOff']}; " - f"operatingMode={controls['operatingMode']}; " - f"heatingPower={controls['heatingPower']}; " - f"tempRoomTarget={controls['tempRoomTarget']};" - ) - async with aiohttp.ClientSession() as session: - async with session.post( - f"{self._base}{API_CONTROLS}", - data=body, - headers={"Content-Type": "text/plain"}, - timeout=aiohttp.ClientTimeout(total=10), - ) as resp: - resp.raise_for_status() + new_controls = self.data.controls.replace(**kwargs) + await self._client.set_controls(new_controls) # Optimistic update: reflect the change immediately in the UI # without waiting for the next poll cycle. - self.async_set_updated_data({**self.data, "controls": controls}) + self.async_set_updated_data(replace(self.data, controls=new_controls)) diff --git a/custom_components/open_firenet/sensor.py b/custom_components/open_firenet/sensor.py index 087bbc3..92fa6d2 100644 --- a/custom_components/open_firenet/sensor.py +++ b/custom_components/open_firenet/sensor.py @@ -30,10 +30,9 @@ async def async_setup_entry( ) -> None: coordinator: OpenFirenetCoordinator = hass.data[DOMAIN][entry.entry_id] - sensors_data: dict = coordinator.data.get("sensors", {}) entities = [ OpenFirenetSensor(coordinator, entry, key) - for key, value in sensors_data.items() + for key, value in coordinator.data.sensors.items() if key not in _SKIP_KEYS and _is_primitive(value) ] async_add_entities(entities) @@ -57,7 +56,7 @@ def __init__( @property def native_value(self): - raw = self.coordinator.data.get("sensors", {}).get(self._key) + raw = self.coordinator.data.sensors.get(self._key) if not _is_primitive(raw): return None try: