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
56 changes: 54 additions & 2 deletions custom_components/open_firenet/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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)
6 changes: 5 additions & 1 deletion custom_components/open_firenet/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())


Expand Down Expand Up @@ -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"]
Expand Down
8 changes: 5 additions & 3 deletions custom_components/open_firenet/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 38 additions & 0 deletions custom_components/open_firenet/diagnostics.py
Original file line number Diff line number Diff line change
@@ -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),
},
}
73 changes: 62 additions & 11 deletions custom_components/open_firenet/sensor.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,55 @@
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

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.<key>.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),
}

# 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 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"}


Expand Down Expand Up @@ -47,24 +79,43 @@ 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 = _TRANSLATION_KEY.get(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):
return raw

@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
21 changes: 21 additions & 0 deletions custom_components/open_firenet/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,26 @@
"abort": {
"already_configured": "This device is already configured."
}
},
"entity": {
"sensor": {
"f0": { "name": "Room 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" },
"internal_temp": { "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" }
}
}
}