Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion custom_components/open_firenet/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
91 changes: 91 additions & 0 deletions custom_components/open_firenet/api.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion custom_components/open_firenet/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 8 additions & 9 deletions custom_components/open_firenet/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
11 changes: 4 additions & 7 deletions custom_components/open_firenet/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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:
Expand Down
43 changes: 10 additions & 33 deletions custom_components/open_firenet/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,65 +2,42 @@

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,
name=DOMAIN,
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))
5 changes: 2 additions & 3 deletions custom_components/open_firenet/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down