From 1659ef1d220bf7a4e1d1366567f40514f913773b Mon Sep 17 00:00:00 2001 From: sguernion Date: Wed, 1 Jul 2026 14:16:58 +0200 Subject: [PATCH 1/3] feat: align HA component with new sensor in open-firenet.ino firmware --- custom_components/open_firenet/__init__.py | 2 +- .../open_firenet/binary_sensor.py | 56 +++++++++++++++- custom_components/open_firenet/climate.py | 6 +- custom_components/open_firenet/const.py | 8 ++- custom_components/open_firenet/diagnostics.py | 38 +++++++++++ custom_components/open_firenet/sensor.py | 64 +++++++++++++++---- custom_components/open_firenet/strings.json | 21 ++++++ 7 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 custom_components/open_firenet/diagnostics.py diff --git a/custom_components/open_firenet/__init__.py b/custom_components/open_firenet/__init__.py index 775a4a7..d3a94b3 100644 --- a/custom_components/open_firenet/__init__.py +++ b/custom_components/open_firenet/__init__.py @@ -7,7 +7,7 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .coordinator import OpenFirenetCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.DIAGNOSTICS] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/custom_components/open_firenet/binary_sensor.py b/custom_components/open_firenet/binary_sensor.py index 825c5ab..6993d6a 100644 --- a/custom_components/open_firenet/binary_sensor.py +++ b/custom_components/open_firenet/binary_sensor.py @@ -5,6 +5,7 @@ BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -17,12 +18,16 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: coordinator: OpenFirenetCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([OpenFirenetConnected(coordinator, entry)]) + async_add_entities([ + OpenFirenetConnected(coordinator, entry), + OpenFirenetWifi(coordinator, entry), + OpenFirenetProvisioning(coordinator, entry), + ]) class OpenFirenetConnected(CoordinatorEntity[OpenFirenetCoordinator], BinarySensorEntity): _attr_has_entity_name = True - _attr_name = "Connected" + _attr_translation_key = "connected" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY def __init__(self, coordinator: OpenFirenetCoordinator, entry: ConfigEntry) -> None: @@ -32,6 +37,53 @@ def __init__(self, coordinator: OpenFirenetCoordinator, entry: ConfigEntry) -> N "identifiers": {(DOMAIN, entry.entry_id)}, } + @property + def available(self) -> bool: + return self.coordinator.last_update_success and self.coordinator.data is not None + @property def is_on(self) -> bool: return self.coordinator.data.get("status", {}).get("mainLoop", False) + + +class OpenFirenetWifi(CoordinatorEntity[OpenFirenetCoordinator], BinarySensorEntity): + _attr_has_entity_name = True + _attr_translation_key = "wifi" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: OpenFirenetCoordinator, entry: ConfigEntry) -> None: + super().__init__(coordinator) + self._attr_unique_id = f"{entry.entry_id}_wifi" + self._attr_device_info = { + "identifiers": {(DOMAIN, entry.entry_id)}, + } + + @property + def available(self) -> bool: + return self.coordinator.last_update_success and self.coordinator.data is not None + + @property + def is_on(self) -> bool: + return self.coordinator.data.get("status", {}).get("wifi", False) + + +class OpenFirenetProvisioning(CoordinatorEntity[OpenFirenetCoordinator], BinarySensorEntity): + _attr_has_entity_name = True + _attr_translation_key = "provisioning" + _attr_device_class = BinarySensorDeviceClass.PROBLEM + + def __init__(self, coordinator: OpenFirenetCoordinator, entry: ConfigEntry) -> None: + super().__init__(coordinator) + self._attr_unique_id = f"{entry.entry_id}_provisioning" + self._attr_device_info = { + "identifiers": {(DOMAIN, entry.entry_id)}, + } + + @property + def available(self) -> bool: + return self.coordinator.last_update_success and self.coordinator.data is not None + + @property + def is_on(self) -> bool: + return self.coordinator.data.get("status", {}).get("provisioning", False) diff --git a/custom_components/open_firenet/climate.py b/custom_components/open_firenet/climate.py index 7ef0d4b..9cd5ac8 100644 --- a/custom_components/open_firenet/climate.py +++ b/custom_components/open_firenet/climate.py @@ -24,7 +24,7 @@ ) from .coordinator import OpenFirenetCoordinator -FAN_MODES = [str(p) for p in range(HEATING_POWER_MIN, HEATING_POWER_MAX + 1, 10)] +FAN_MODES = [str(p) for p in range(HEATING_POWER_MIN, HEATING_POWER_MAX + 1, 5)] PRESET_MODES = list(OPERATING_MODES.values()) @@ -64,6 +64,10 @@ def __init__(self, coordinator: OpenFirenetCoordinator, entry: ConfigEntry) -> N "configuration_url": f"http://{coordinator.host}", } + @property + def available(self) -> bool: + return self.coordinator.last_update_success and self.coordinator.data is not None + @property def _controls(self) -> dict: return self.coordinator.data["controls"] diff --git a/custom_components/open_firenet/const.py b/custom_components/open_firenet/const.py index 0bf2486..d9912e4 100644 --- a/custom_components/open_firenet/const.py +++ b/custom_components/open_firenet/const.py @@ -14,12 +14,14 @@ OPERATING_MODES_REVERSE = {v: k for k, v in OPERATING_MODES.items()} # Candidate sensor keys for current room temperature (×10 encoding, e.g. 195 = 19.5°C). -# The correct key depends on what the stove sends in POST_SENSORS. -ROOM_TEMP_KEYS = ["f0", "tRoom", "temperatureRoom", "room_temp", "T_room"] +# f0 is sRoomTemp_ACT from POST_SENSORS (confirmed in firmware comments). +ROOM_TEMP_KEYS = ["f0", "sRoomTemp_ACT", "tRoom", "temperatureRoom", "room_temp", "T_room"] +# Wire range is 140–280 (×10), i.e. 14.0–28.0°C TEMP_MIN = 14.0 TEMP_MAX = 28.0 TEMP_STEP = 1.0 -HEATING_POWER_MIN = 30 +# Firmware enforces heatingPower >= 50 in parseDesiredControls and adjPow +HEATING_POWER_MIN = 50 HEATING_POWER_MAX = 100 diff --git a/custom_components/open_firenet/diagnostics.py b/custom_components/open_firenet/diagnostics.py new file mode 100644 index 0000000..4223da7 --- /dev/null +++ b/custom_components/open_firenet/diagnostics.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import OpenFirenetCoordinator + +_REDACT = {"mac", "ip", "ssid"} + + +def _sanitize(data: Any) -> Any: + if isinstance(data, dict): + return { + k: "**REDACTED**" if k in _REDACT else _sanitize(v) + for k, v in data.items() + } + if isinstance(data, list): + return [_sanitize(v) for v in data] + return data + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + coordinator: OpenFirenetCoordinator = hass.data[DOMAIN][entry.entry_id] + return { + "entry": { + "host": entry.data.get("host"), + "scan_interval": entry.data.get("scan_interval"), + }, + "coordinator": { + "last_update_success": coordinator.last_update_success, + "data": _sanitize(coordinator.data), + }, + } diff --git a/custom_components/open_firenet/sensor.py b/custom_components/open_firenet/sensor.py index 087bbc3..ff99766 100644 --- a/custom_components/open_firenet/sensor.py +++ b/custom_components/open_firenet/sensor.py @@ -1,7 +1,14 @@ from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -9,15 +16,31 @@ from .const import DOMAIN from .coordinator import OpenFirenetCoordinator -# Known sensor keys with human labels and optional unit -KNOWN_SENSORS: dict[str, tuple[str, str | None]] = { - "stoveOnOff": ("Stove On/Off", None), - "stoveOpMode": ("Operating Mode", None), - "stovePower": ("Heating Power", "%"), - "stoveTempTarget": ("Target Temperature Raw", None), +_D = EntityCategory.DIAGNOSTIC + +# Sensors from /api/sensors: key → (unit, device_class, divide_by_10, entity_category, state_class_override) +# divide_by_10=True for stove values encoded as integer×10 (e.g. 233 = 23.3°C) +# state_class_override=None falls back to the generic float→MEASUREMENT heuristic +# Names are resolved via translation_key → strings.json entity.sensor..name +KNOWN_SENSORS: dict[str, tuple[str | None, str | None, bool, EntityCategory | None, SensorStateClass | None]] = { + # Stove POST_SENSORS fields (f0 = sRoomTemp_ACT, f1..fN = other stove fields) + "f0": (UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, True, None, None), + # Stove POST_CONTROLS fields exposed by the firmware + "stoveOnOff": (None, None, False, None, None), + "stoveOpMode": (None, None, False, None, None), + "stovePower": (PERCENTAGE, None, False, None, None), + "stoveTempTarget": (UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, True, None, None), + # ESP32 system sensors — diagnostic + "uptime": (UnitOfTime.SECONDS, SensorDeviceClass.DURATION, False, _D, SensorStateClass.TOTAL_INCREASING), + "internalTemp": (UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE, False, _D, None), + "rssi": (SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SensorDeviceClass.SIGNAL_STRENGTH, False, _D, None), + "firmware": (None, None, False, _D, None), + "mac": (None, None, False, _D, None), + "ip": (None, None, False, _D, None), + "ssid": (None, None, False, _D, None), } -# Keys handled by other entities or not suitable as sensors +# Keys exposed by /api/controls and handled by the climate entity _SKIP_KEYS = {"onOff", "operatingMode", "heatingPower", "tempRoomTarget"} @@ -47,19 +70,34 @@ def __init__( ) -> None: super().__init__(coordinator) self._key = key - label, unit = KNOWN_SENSORS.get(key, (key, None)) - self._attr_name = label + info = KNOWN_SENSORS.get(key, (None, None, False, None, None)) + unit, device_class, self._divide_by_10, entity_category, self._state_class_override = info + if key in KNOWN_SENSORS: + self._attr_translation_key = key + else: + self._attr_name = key self._attr_native_unit_of_measurement = unit + self._attr_device_class = device_class + self._attr_entity_category = entity_category self._attr_unique_id = f"{entry.entry_id}_sensor_{key}" self._attr_device_info = { "identifiers": {(DOMAIN, entry.entry_id)}, } + @property + def available(self) -> bool: + return self.coordinator.last_update_success and self.coordinator.data is not None + @property def native_value(self): raw = self.coordinator.data.get("sensors", {}).get(self._key) if not _is_primitive(raw): return None + if self._divide_by_10: + try: + return float(raw) / 10 + except (TypeError, ValueError): + return None try: return float(raw) except (TypeError, ValueError): @@ -67,4 +105,8 @@ def native_value(self): @property def state_class(self) -> SensorStateClass | None: - return SensorStateClass.MEASUREMENT if isinstance(self.native_value, float) else None + if self._state_class_override is not None: + return self._state_class_override + if isinstance(self.native_value, float): + return SensorStateClass.MEASUREMENT + return None diff --git a/custom_components/open_firenet/strings.json b/custom_components/open_firenet/strings.json index d31a3aa..4845183 100644 --- a/custom_components/open_firenet/strings.json +++ b/custom_components/open_firenet/strings.json @@ -21,5 +21,26 @@ "abort": { "already_configured": "This device is already configured." } + }, + "entity": { + "sensor": { + "f0": { "name": "Room Temperature" }, + "stoveOnOff": { "name": "Stove On/Off" }, + "stoveOpMode": { "name": "Operating Mode" }, + "stovePower": { "name": "Heating Power" }, + "stoveTempTarget": { "name": "Target Temperature" }, + "uptime": { "name": "Uptime" }, + "internalTemp": { "name": "ESP32 Chip Temperature" }, + "rssi": { "name": "WiFi Signal Strength" }, + "firmware": { "name": "Firmware Version" }, + "mac": { "name": "MAC Address" }, + "ip": { "name": "IP Address" }, + "ssid": { "name": "WiFi Network" } + }, + "binary_sensor": { + "connected": { "name": "Stove Connected" }, + "wifi": { "name": "WiFi Connected" }, + "provisioning": { "name": "Provisioning Mode" } + } } } From f84d014436a284867a3d4bcf1a34ebd8a968ef05 Mon Sep 17 00:00:00 2001 From: sguernion Date: Mon, 6 Jul 2026 20:13:59 +0200 Subject: [PATCH 2/3] fix: rename camelCase translation keys to snake_case --- custom_components/open_firenet/sensor.py | 11 ++++++++++- custom_components/open_firenet/strings.json | 10 +++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/custom_components/open_firenet/sensor.py b/custom_components/open_firenet/sensor.py index ff99766..fa35aa3 100644 --- a/custom_components/open_firenet/sensor.py +++ b/custom_components/open_firenet/sensor.py @@ -40,6 +40,15 @@ "ssid": (None, None, False, _D, None), } +# HA translation keys must match [a-z0-9-_]+; map camelCase API keys to snake_case +_TRANSLATION_KEY: dict[str, str] = { + "stoveOnOff": "stove_on_off", + "stoveOpMode": "stove_op_mode", + "stovePower": "stove_power", + "stoveTempTarget": "stove_temp_target", + "internalTemp": "internal_temp", +} + # Keys exposed by /api/controls and handled by the climate entity _SKIP_KEYS = {"onOff", "operatingMode", "heatingPower", "tempRoomTarget"} @@ -73,7 +82,7 @@ def __init__( info = KNOWN_SENSORS.get(key, (None, None, False, None, None)) unit, device_class, self._divide_by_10, entity_category, self._state_class_override = info if key in KNOWN_SENSORS: - self._attr_translation_key = key + self._attr_translation_key = _TRANSLATION_KEY.get(key, key) else: self._attr_name = key self._attr_native_unit_of_measurement = unit diff --git a/custom_components/open_firenet/strings.json b/custom_components/open_firenet/strings.json index 4845183..43f5a1d 100644 --- a/custom_components/open_firenet/strings.json +++ b/custom_components/open_firenet/strings.json @@ -25,12 +25,12 @@ "entity": { "sensor": { "f0": { "name": "Room Temperature" }, - "stoveOnOff": { "name": "Stove On/Off" }, - "stoveOpMode": { "name": "Operating Mode" }, - "stovePower": { "name": "Heating Power" }, - "stoveTempTarget": { "name": "Target Temperature" }, + "stove_on_off": { "name": "Stove On/Off" }, + "stove_op_mode": { "name": "Operating Mode" }, + "stove_power": { "name": "Heating Power" }, + "stove_temp_target": { "name": "Target Temperature" }, "uptime": { "name": "Uptime" }, - "internalTemp": { "name": "ESP32 Chip Temperature" }, + "internal_temp": { "name": "ESP32 Chip Temperature" }, "rssi": { "name": "WiFi Signal Strength" }, "firmware": { "name": "Firmware Version" }, "mac": { "name": "MAC Address" }, From b4a7e4e3196877301ea08c6f0fe8ebc7a9c226b9 Mon Sep 17 00:00:00 2001 From: sguernion Date: Mon, 6 Jul 2026 20:35:15 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20remove=20Platform.DIAGNOSTICS=20?= =?UTF-8?q?=E2=80=94=20diagnostics=20is=20not=20a=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/open_firenet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/open_firenet/__init__.py b/custom_components/open_firenet/__init__.py index d3a94b3..775a4a7 100644 --- a/custom_components/open_firenet/__init__.py +++ b/custom_components/open_firenet/__init__.py @@ -7,7 +7,7 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .coordinator import OpenFirenetCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.DIAGNOSTICS] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: