diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffbe8f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + .DS_Store + \ No newline at end of file diff --git a/custom_components/boiler_controller/__init__.py b/custom_components/boiler_controller/__init__.py index 835e957..59b1d92 100644 --- a/custom_components/boiler_controller/__init__.py +++ b/custom_components/boiler_controller/__init__.py @@ -1,110 +1,57 @@ -import logging +"""Boiler Controller Home Assistant integration.""" +from __future__ import annotations -import voluptuous as vol +import logging -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv from homeassistant.loader import async_get_integration -from .const import ( - DOMAIN, - PLATFORMS, - SERVICE_RUN_CALIBRATION, - SERVICE_CANCEL_CALIBRATION, - ATTR_CONFIG_ENTRY_ID, - CALIBRATION_START_PERCENTAGE, - CALIBRATION_END_PERCENTAGE, - CALIBRATION_STEP_PERCENTAGE, - CALIBRATION_SETTLE_SECONDS, -) +from .const import DOMAIN, PLATFORMS, VERSION from .controller import BoilerController _LOGGER = logging.getLogger(__name__) -ENTRY_ID_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_CONFIG_ENTRY_ID): cv.string, - } -) -RUN_CALIBRATION_SCHEMA = ENTRY_ID_SCHEMA -CANCEL_CALIBRATION_SCHEMA = ENTRY_ID_SCHEMA - -# Set up the component async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Boiler Controller from a config entry.""" _LOGGER.info("Setting up Boiler Controller") - - from .const import VERSION - + try: integration = await async_get_integration(hass, DOMAIN) - integration_version = integration.version + integration_version = str(integration.version) except Exception as err: # pylint: disable=broad-except - _LOGGER.warning("Could not get integration version: %s, using fallback", err) - integration_version = None - - # Ensure we always have a valid version string - integration_version = str(integration_version) if integration_version else VERSION + _LOGGER.warning("Could not get integration version: %s", err) + integration_version = VERSION - # Create the controller controller = BoilerController(hass, entry, integration_version) - - # Start the controller (now handles missing entities gracefully) success = await controller.async_start() if not success: - _LOGGER.error("Failed to start Boiler Controller") - # Don't raise ConfigEntryNotReady anymore - let it start and wait for entities - _LOGGER.warning("Boiler Controller will continue running and wait for entities to become available") - - # Store the controller - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "controller": controller, - } - - await _async_register_services(hass) - - # Set up platforms + _LOGGER.warning("Boiler Controller started with warnings – will retry") + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"controller": controller} + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - + _LOGGER.info("Boiler Controller setup completed") return True -# Implement unloading and reloading of the config entry + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" _LOGGER.info("Unloading Boiler Controller") - - # Unload platforms + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - # Stop the controller + controller_data = hass.data.get(DOMAIN, {}).get(entry.entry_id) if controller_data: controller = controller_data.get("controller") if controller: await controller.async_stop() - - # Remove from hass.data + if DOMAIN in hass.data and entry.entry_id in hass.data[DOMAIN]: hass.data[DOMAIN].pop(entry.entry_id) - domain_data = hass.data.get(DOMAIN, {}) - remaining_controllers = [ - value - for value in domain_data.values() - if isinstance(value, dict) and value.get("controller") - ] - - if not remaining_controllers: - if hass.services.has_service(DOMAIN, SERVICE_RUN_CALIBRATION): - hass.services.async_remove(DOMAIN, SERVICE_RUN_CALIBRATION) - if hass.services.has_service(DOMAIN, SERVICE_CANCEL_CALIBRATION): - hass.services.async_remove(DOMAIN, SERVICE_CANCEL_CALIBRATION) - domain_data.pop("_services_registered", None) - return unload_ok @@ -112,83 +59,3 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload config entry.""" await async_unload_entry(hass, entry) await async_setup_entry(hass, entry) - - -async def _async_register_services(hass: HomeAssistant) -> None: - """Register the calibration service once per Home Assistant instance.""" - - domain_data = hass.data.setdefault(DOMAIN, {}) - if domain_data.get("_services_registered"): - return - - async def _handle_run_calibration(call: ServiceCall) -> None: - controller = _async_resolve_controller(hass, call.data.get(ATTR_CONFIG_ENTRY_ID)) - - min_pct = CALIBRATION_START_PERCENTAGE - max_pct = CALIBRATION_END_PERCENTAGE - if max_pct < min_pct: - raise HomeAssistantError("max_percentage must be greater than or equal to min_percentage") - - _LOGGER.info("Starting calibration for Boiler Controller entry %s", controller.config_entry.entry_id) - profile = await controller.async_run_calibration( - min_percentage=min_pct, - max_percentage=max_pct, - step_percentage=CALIBRATION_STEP_PERCENTAGE, - settle_seconds=CALIBRATION_SETTLE_SECONDS, - ) - - points_recorded = len(profile.get("points", [])) if profile else 0 - _LOGGER.info( - "Calibration completed for entry %s (%s points recorded)", - controller.config_entry.entry_id, - points_recorded, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_RUN_CALIBRATION, - _handle_run_calibration, - schema=RUN_CALIBRATION_SCHEMA, - ) - - async def _handle_cancel_calibration(call: ServiceCall) -> None: - controller = _async_resolve_controller(hass, call.data.get(ATTR_CONFIG_ENTRY_ID)) - - requested = await controller.async_request_calibration_cancel() - if not requested: - raise HomeAssistantError("No calibration run is currently active") - - _LOGGER.info( - "Calibration cancellation requested for entry %s", - controller.config_entry.entry_id, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_CANCEL_CALIBRATION, - _handle_cancel_calibration, - schema=CANCEL_CALIBRATION_SCHEMA, - ) - domain_data["_services_registered"] = True - - -def _async_resolve_controller(hass: HomeAssistant, entry_id: str | None) -> BoilerController: - controllers = { - key: value["controller"] - for key, value in hass.data.get(DOMAIN, {}).items() - if isinstance(value, dict) and value.get("controller") - } - - if not controllers: - raise HomeAssistantError("No Boiler Controller entries loaded") - - if entry_id: - controller = controllers.get(entry_id) - if not controller: - raise HomeAssistantError(f"No Boiler Controller entry with id {entry_id}") - return controller - - if len(controllers) == 1: - return next(iter(controllers.values())) - - raise HomeAssistantError("config_entry_id is required when multiple Boiler Controller entries exist") diff --git a/custom_components/boiler_controller/boiler_client.py b/custom_components/boiler_controller/boiler_client.py new file mode 100644 index 0000000..faf1232 --- /dev/null +++ b/custom_components/boiler_controller/boiler_client.py @@ -0,0 +1,116 @@ +"""Client for interacting with the Boiler Controller module via HTTP API.""" +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +import aiohttp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + + +class BoilerClient: + """HTTP API client for the Boiler Controller module.""" + + def __init__(self, hass: HomeAssistant, host: str) -> None: + self.hass = hass + # host can be IP or mDNS hostname, e.g. "192.168.1.100" + # or "boiler-controller-abcd1234.local" + self._host = host.strip().rstrip("/") + self._base_url = f"http://{self._host}" + self._session = async_get_clientsession(hass) + + @property + def host(self) -> str: + return self._host + + async def async_get_status(self) -> Optional[Dict[str, Any]]: + """Fetch /api/status from the module. + + Returns a dict like: + { + "power": 1320, + "heatingPercentage": 60, + "temperature": 65.0, + "total": 12345, + "rssi": -50 + } + """ + url = f"{self._base_url}/api/status" + try: + async with self._session.get( + url, timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + if resp.status == 200: + data = await resp.json(content_type=None) + _LOGGER.debug("Module status: %s", data) + return data + _LOGGER.warning("Module /api/status returned HTTP %s", resp.status) + except aiohttp.ClientError as err: + _LOGGER.warning("Module /api/status error: %s", err) + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Unexpected error fetching /api/status: %s", err) + return None + + async def async_get_system(self) -> Optional[Dict[str, Any]]: + """Fetch /api/system from the module. + + Returns a dict like: + { + "system": { + "firmwareVersion": 1, + "cpuFrequency": "240 MHz", + "ip": "192.168.1.123", + "currentDateTime": "2026-04-23 20:15:00", + "upSince": "2026-04-22 11:03:18", + "wifiStrength": -58 + } + } + """ + url = f"{self._base_url}/api/system" + try: + async with self._session.get( + url, timeout=aiohttp.ClientTimeout(total=10) + ) as resp: + if resp.status == 200: + data = await resp.json(content_type=None) + _LOGGER.debug("Module system info: %s", data) + return data + _LOGGER.warning("Module /api/system returned HTTP %s", resp.status) + except aiohttp.ClientError as err: + _LOGGER.warning("Module /api/system error: %s", err) + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Unexpected error fetching /api/system: %s", err) + return None + + async def async_set_heat(self, percentage: int) -> bool: + """Set heating percentage via /api/heat?percentage=XX (0-100).""" + percentage = max(0, min(100, int(percentage))) + url = f"{self._base_url}/api/heat" + try: + async with self._session.get( + url, + params={"percentage": percentage}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status == 200: + _LOGGER.debug("Set heating to %s%%", percentage) + return True + _LOGGER.warning( + "Module /api/heat?percentage=%s returned HTTP %s", + percentage, + resp.status, + ) + except aiohttp.ClientError as err: + _LOGGER.warning("Module /api/heat error: %s", err) + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Unexpected error calling /api/heat: %s", err) + return False + + async def async_test_connection(self) -> bool: + """Test connectivity by fetching /api/status.""" + status = await self.async_get_status() + return status is not None diff --git a/custom_components/boiler_controller/button.py b/custom_components/boiler_controller/button.py index 49c5077..6aeec26 100644 --- a/custom_components/boiler_controller/button.py +++ b/custom_components/boiler_controller/button.py @@ -6,31 +6,22 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - DOMAIN, - VERSION, - CALIBRATION_START_PERCENTAGE, - CALIBRATION_END_PERCENTAGE, - CALIBRATION_STEP_PERCENTAGE, - CALIBRATION_SETTLE_SECONDS, -) +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) def _device_info(config_entry: ConfigEntry, controller) -> Dict[str, Any]: from .const import VERSION - version = controller.integration_version if controller.integration_version else VERSION + version = controller.integration_version or VERSION return { "identifiers": {(DOMAIN, config_entry.entry_id)}, "name": config_entry.title, "manufacturer": "Boiler Controller", - "model": "P1 to Shelly Controller", + "model": "Boiler Controller Module", "sw_version": str(version), } @@ -40,122 +31,35 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - controller_data = hass.data[DOMAIN][config_entry.entry_id] - controller = controller_data["controller"] - + controller = hass.data[DOMAIN][config_entry.entry_id]["controller"] async_add_entities( [ - BoilerCalibrationButton(hass, config_entry, controller), - BoilerCalibrationStopButton(hass, config_entry, controller), + BoilerStopButton(hass, config_entry, controller), ], True, ) -class _BaseCalibrationButton(ButtonEntity): - """Shared behavior for calibration buttons.""" +class BoilerStopButton(ButtonEntity): + """Button that immediately sets the boiler heating to 0%.""" _attr_should_poll = False + _attr_icon = "mdi:stop-circle-outline" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, controller + ) -> None: self.hass = hass self.config_entry = config_entry self.controller = controller - self._remove_calibration_listener: Any | None = None - - async def async_added_to_hass(self) -> None: - self._remove_calibration_listener = async_dispatcher_connect( - self.hass, - self.controller.get_calibration_state_signal(), - self._handle_calibration_state, - ) - self.async_write_ha_state() - - async def async_will_remove_from_hass(self) -> None: - if self._remove_calibration_listener: - self._remove_calibration_listener() - self._remove_calibration_listener = None + self._attr_name = f"{config_entry.title} Stop Heating" + self._attr_unique_id = f"{config_entry.entry_id}_stop_heating" - @callback - def _handle_calibration_state(self, *_: Any) -> None: - self.async_write_ha_state() + async def async_press(self) -> None: + """Stop boiler heating immediately.""" + _LOGGER.info("Stop heating button pressed") + await self.controller.boiler_client.async_set_heat(0) @property def device_info(self) -> Dict[str, Any]: return _device_info(self.config_entry, self.controller) - - async def _async_notify(self, message: str) -> None: - notification_id = f"boiler_controller_calibration_{self.config_entry.entry_id}" - await self.hass.services.async_call( - "persistent_notification", - "create", - { - "title": "Boiler Controller", - "message": message, - "notification_id": notification_id, - }, - blocking=False, - ) - - -class BoilerCalibrationButton(_BaseCalibrationButton): - """Button that triggers a calibration run on the Shelly dimmer.""" - - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) -> None: - super().__init__(hass, config_entry, controller) - self._attr_name = "Calibrate Start" - self._attr_unique_id = f"{config_entry.entry_id}_calibrate_device" - self._attr_icon = "mdi:chart-bell-curve" - - async def async_press(self) -> None: - if self.controller.is_calibration_active: - message = f"Calibration already active for {self.config_entry.title}" - _LOGGER.warning(message) - raise HomeAssistantError(message) - - _LOGGER.info("Calibration button pressed for %s", self.config_entry.title) - - async def _run_calibration() -> None: - try: - await self.controller.async_run_calibration( - min_percentage=CALIBRATION_START_PERCENTAGE, - max_percentage=CALIBRATION_END_PERCENTAGE, - step_percentage=CALIBRATION_STEP_PERCENTAGE, - settle_seconds=CALIBRATION_SETTLE_SECONDS, - ) - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Calibration failed for %s: %s", self.config_entry.title, err) - await self._async_notify(f"Calibration failed: {err}") - - self.hass.async_create_task(_run_calibration()) - - @property - def available(self) -> bool: - return not self.controller.is_calibration_active - - -class BoilerCalibrationStopButton(_BaseCalibrationButton): - """Button that cancels the ongoing calibration sweep.""" - - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) -> None: - super().__init__(hass, config_entry, controller) - self._attr_name = "Calibrate Stop" - self._attr_unique_id = f"{config_entry.entry_id}_stop_calibration" - self._attr_icon = "mdi:stop-circle" - - async def async_press(self) -> None: - if not self.controller.is_calibration_active: - raise HomeAssistantError("Calibration is not currently running") - - requested = await self.controller.async_request_calibration_cancel() - if not requested: - raise HomeAssistantError("No calibration run to cancel") - - _LOGGER.info("Calibration cancellation requested via button for %s", self.config_entry.title) - await self._async_notify( - "Calibration cancellation requested. The sweep will stop after the current step." - ) - - @property - def available(self) -> bool: - return self.controller.is_calibration_active \ No newline at end of file diff --git a/custom_components/boiler_controller/calculator.py b/custom_components/boiler_controller/calculator.py index a73214d..098fdaf 100644 --- a/custom_components/boiler_controller/calculator.py +++ b/custom_components/boiler_controller/calculator.py @@ -1,334 +1,111 @@ -"""Logic for translating power readings into Shelly dimmer percentages.""" +"""Heating percentage calculator for the Boiler Controller.""" from __future__ import annotations -import logging +import logging from dataclasses import dataclass, field +from typing import Optional -from .const import MAX_EXPORT_WATTS +from .const import DEFAULT_MAX_BOILER_WATTS _LOGGER = logging.getLogger(__name__) -# Default calibration profile from 20% to 100% in 1% steps. -DEFAULT_CALIBRATION_PROFILE: list[tuple[int, float]] = [ - (20, 555.0), - (21, 554.0), - (22, 563.0), - (23, 597.0), - (24, 547.0), - (25, 560.0), - (26, 554.0), - (27, 586.0), - (28, 618.0), - (29, 631.0), - (30, 677.0), - (31, 718.0), - (32, 779.0), - (33, 797.0), - (34, 848.0), - (35, 854.0), - (36, 945.0), - (37, 969.0), - (38, 1031.0), - (39, 1062.0), - (40, 1092.0), - (41, 1155.0), - (42, 1158.0), - (43, 1196.0), - (44, 1255.0), - (45, 1258.0), - (46, 1291.0), - (47, 1325.0), - (48, 1348.0), - (49, 1377.0), - (50, 1428.0), - (51, 1426.0), - (52, 1484.0), - (53, 1489.0), - (54, 1503.0), - (55, 1525.0), - (56, 1519.0), - (57, 1157.0), - (58, 1238.0), - (59, 1285.0), - (60, 1312.0), - (61, 1322.0), - (62, 1327.0), - (63, 1347.0), - (64, 1421.0), - (65, 1448.0), - (66, 1444.0), - (67, 1463.0), - (68, 1520.0), - (69, 1519.0), - (70, 1564.0), - (71, 1560.0), - (72, 1586.0), - (73, 1615.0), - (74, 1613.0), - (75, 1650.0), - (76, 1647.0), - (77, 1641.0), - (78, 1646.0), - (79, 1654.0), - (80, 1644.0), - (81, 1664.0), - (82, 1671.0), - (83, 1676.0), - (84, 1679.0), - (85, 1705.0), - (86, 1687.0), - (87, 1688.0), - (88, 1697.0), - (89, 1701.0), - (90, 1715.0), - (91, 1710.0), - (92, 1712.0), - (93, 1713.0), - (94, 1721.0), - (95, 1719.0), - (96, 1724.0), - (97, 1725.0), - (98, 1717.0), - (99, 1715.0), - (100, 1720.0), -] -DEFAULT_PERCENTAGE_PROFILE: dict[int, float] = { - percentage: watts for percentage, watts in DEFAULT_CALIBRATION_PROFILE -} -DEFAULT_PERCENTAGE_PROFILE[0] = 0.0 +@dataclass +class CalculatorResult: + """Result of a single calculator run.""" -DEFAULT_THRESHOLD_TABLE: list[tuple[float, int]] = [(0.0, 0)] + sorted( - [(watts, percentage) for percentage, watts in DEFAULT_CALIBRATION_PROFILE], - key=lambda item: item[0], -) + target_percentage: int + new_percentage: int + available_watts: float + grid_watts: float + boiler_watts: float + current_percentage: int + max_boiler_watts: float + capped: bool = False -# TODO - we should automate changes of this profile over time based on observed performance drift. -@dataclass(slots=True) +@dataclass class Calculator: - """Encapsulate the dimmer percentage calculation logic.""" + """Translate available surplus power into a heating percentage. - # List of (watt_threshold, dimmer_percentage) tuples. - calibration_profile: list[tuple[float, int]] | None = None - # holds the source of the currently used calibration profile - # "calibration" if from a calibration profile, "default" if we are using the default curve. - _calibration_profile_source: str = field(init=False, default="default") - # Mapping of percentage -> watts for the active profile (includes 0% -> 0W). - _percentage_profile: dict[int, float] = field(init=False, default_factory=dict) - # Debug trace of the last calculation performed. - _last_trace: dict[str, float | int | str] | None = field(init=False, default=None) + Logic: + 1. ``available_watts = current_boiler_watts - grid_net_watts`` + - When the grid value is negative (export/surplus) this grows. + - When the grid value is positive (import) this shrinks. + 2. ``target_pct = round(available_watts / max_boiler_watts * 100)`` + clamped to [0, 100]. + 3. The new percentage steps at most ``max_step`` toward the target + so the boiler ramps up/down gradually. + """ - def __post_init__(self): - initial_profile = list(self.calibration_profile) if self.calibration_profile else None - self.set_calibration_profile(initial_profile) + max_boiler_watts: float = float(DEFAULT_MAX_BOILER_WATTS) + max_step: int = 10 + + # Last trace — useful for debugging / diagnostics + last_result: Optional[CalculatorResult] = field(init=False, default=None) def calculate( self, - power_value: float, - min_dimmer: int, - max_dimmer: int, - *, - boiler_consumption: float | None = None, - current_dimmer: int | None = None, + grid_watts: float, + current_percentage: int, + boiler_watts: float = 0.0, ) -> int: - """Return the dimmer percentage for the given power value.""" - - boiler_watts = max(0.0, float(boiler_consumption)) if boiler_consumption else 0.0 - grid_flow_watts = float(power_value) - current_percentage = self._normalize_percentage(current_dimmer) - # Determine the expected boiler wattage for the current dimmer setting. - # It looks up the calibration profile (or default curve) to find the expected wattage - expected_watts = self._expected_watts_for_percentage(current_percentage) - # Use either the expected wattage or the actual boiler consumption as the baseline. - baseline_watts = expected_watts if expected_watts > 0 else boiler_watts - - # Rebuild the available export based on the calibration profile of the current dimmer value. - target_watts = max(0.0, baseline_watts - grid_flow_watts) - - clipped = False - if target_watts > MAX_EXPORT_WATTS: - _LOGGER.info( - "Target %.1f W exceeds hard limit %.1f W; capping to %.1f W", - target_watts, - MAX_EXPORT_WATTS, - MAX_EXPORT_WATTS, - ) - target_watts = MAX_EXPORT_WATTS - clipped = True - - if target_watts <= 0: - self._last_trace = { - "source": "calibration profile" if self._calibration_profile_source == "calibration" else "default curve", - "grid_flow_watts": grid_flow_watts, - "boiler_watts": boiler_watts, - "current_dimmer": current_percentage, - "expected_watts": expected_watts, - "target_watts": target_watts, - "note": "no_surplus", - } - _LOGGER.info( - "Insufficient surplus (grid %.1f W, boiler %.1f W); keeping dimmer at 0%%", - grid_flow_watts, - boiler_watts, - ) - return 0 - - source_label = "calibration profile" if self._calibration_profile_source == "calibration" else "default curve" - # Find the matching segment/percentage in the calibration profile. - thresholds = self.calibration_profile or list(DEFAULT_THRESHOLD_TABLE) - if not thresholds: - thresholds = list(DEFAULT_THRESHOLD_TABLE) - - # Walk the thresholds to find the matching segment. - lower_limit, lower_percentage = thresholds[0] - upper_limit, upper_percentage = thresholds[-1] - selected_percentage = upper_percentage - match_index = len(thresholds) - 1 - - for idx in range(1, len(thresholds)): - limit, percentage = thresholds[idx] - if target_watts < limit: - upper_limit = limit - upper_percentage = percentage - match_index = idx - selected_percentage = lower_percentage - break - if target_watts == limit: - lower_limit = limit - lower_percentage = percentage - upper_limit = limit - upper_percentage = percentage - match_index = idx - selected_percentage = percentage - break - lower_limit = limit - lower_percentage = percentage + """Return the new heating percentage. + + Args: + grid_watts: Net grid power in W (positive = importing, + negative = exporting surplus). + current_percentage: Current heating percentage reported by the + module (0-100). + boiler_watts: Current measured boiler consumption in W. + Used to reconstruct the available surplus. + """ + boiler_w = max(0.0, float(boiler_watts)) + grid_w = float(grid_watts) + current_pct = max(0, min(100, int(current_percentage))) + + # Available watts the boiler can use without importing from the grid. + available = max(0.0, boiler_w - grid_w) + + # Cap to boiler maximum capacity. + capped = available > self.max_boiler_watts + available_clamped = min(available, self.max_boiler_watts) + + target_pct = int(round(available_clamped / self.max_boiler_watts * 100.0)) + target_pct = max(0, min(100, target_pct)) + + # Dynamic step: use at least max_step, but when the gap is large take + # half the remaining distance so the boiler ramps up/down faster. + diff = abs(target_pct - current_pct) + step = min(diff, max(self.max_step, diff // 2)) + if target_pct > current_pct: + new_pct = current_pct + step + elif target_pct < current_pct: + new_pct = current_pct - step else: - lower_limit, lower_percentage = thresholds[-1] - upper_limit, upper_percentage = lower_limit, lower_percentage - match_index = len(thresholds) - 1 - selected_percentage = upper_percentage - - # Build the final percentage within min/max limits. - final_percentage = max(min_dimmer, min(max_dimmer, selected_percentage)) - - self._last_trace = { - "source": source_label, - "grid_flow_watts": grid_flow_watts, - "boiler_watts": boiler_watts, - "current_dimmer": current_percentage, - "expected_watts": expected_watts, - "target_watts": target_watts, - "segment_lower_watts": lower_limit, - "segment_lower_percentage": lower_percentage, - "segment_upper_watts": upper_limit, - "segment_upper_percentage": upper_percentage, - "segment_index": match_index + 1, - "selected_percentage": selected_percentage, - "note": "hard_cap" if clipped else "segment", - } - _LOGGER.info( - "Grid %.1f W, boiler %.1f W, dimmer %s%% -> baseline %.1f W, target %.1f W%s; selected %d%% via %s point #%d (lower %.1f W @ %d%%, upper %.1f W @ %d%%)", - grid_flow_watts, - boiler_watts, - current_percentage, - expected_watts, - target_watts, - " [capped]" if clipped else "", - final_percentage, - source_label, - match_index + 1, - lower_limit, - lower_percentage, - upper_limit, - upper_percentage, + new_pct = current_pct + + self.last_result = CalculatorResult( + target_percentage=target_pct, + new_percentage=new_pct, + available_watts=available, + grid_watts=grid_w, + boiler_watts=boiler_w, + current_percentage=current_pct, + max_boiler_watts=self.max_boiler_watts, + capped=capped, ) - return final_percentage - - def set_calibration_profile(self, profile: list[tuple[float, int]] | None) -> None: - """Install a new watt-to-percentage table for future calculations.""" - if not profile: - self.calibration_profile = list(DEFAULT_THRESHOLD_TABLE) - self._percentage_profile = dict(DEFAULT_PERCENTAGE_PROFILE) - self._calibration_profile_source = "default" - self._last_trace = None - return - - sanitized: list[tuple[float, int]] = [] - percentage_profile: dict[int, float] = {} - for watts, percentage in profile: - try: - clean_watts = max(0.0, float(watts)) - clean_percentage = max(0, min(100, int(percentage))) - except (TypeError, ValueError): - continue - sanitized.append((clean_watts, clean_percentage)) - percentage_profile[clean_percentage] = clean_watts - - sanitized.sort(key=lambda item: item[0]) - if not sanitized: - self.calibration_profile = list(DEFAULT_THRESHOLD_TABLE) - self._percentage_profile = dict(DEFAULT_PERCENTAGE_PROFILE) - self._calibration_profile_source = "default" - self._last_trace = None - return - - first_watts, first_percentage = sanitized[0] - if first_watts != 0.0 or first_percentage != 0: - sanitized.insert(0, (0.0, 0)) - percentage_profile.setdefault(0, 0.0) - - self.calibration_profile = sanitized - self._percentage_profile = dict(percentage_profile) - self._calibration_profile_source = "calibration" - self._last_trace = None - - def _expected_watts_for_percentage(self, percentage: int | None) -> float: - """Return the expected watts for a given dimmer percentage based on the profile.""" - - if percentage is None or percentage <= 0: - return 0.0 - - profile = self._percentage_profile or {} - if not profile: - return 0.0 - - if percentage in profile: - return profile[percentage] - - sorted_percentages = sorted(profile.keys()) - lower = None - upper = None - for value in sorted_percentages: - if value < percentage: - lower = value - continue - if value > percentage: - upper = value - break - - if lower is None and upper is None: - return 0.0 - if lower is None: - return profile[upper] - if upper is None: - return profile[lower] - - lower_watts = profile[lower] - upper_watts = profile[upper] - span = max(1, upper - lower) - position = (percentage - lower) / span - return lower_watts + (upper_watts - lower_watts) * position - - @staticmethod - def _normalize_percentage(value: int | float | None) -> int: - """Clamp raw dimmer readings to the valid 0-100% range.""" + _LOGGER.debug( + "Calculator: grid=%.1fW boiler=%.1fW available=%.1fW " + "target=%d%% current=%d%% → new=%d%%%s", + grid_w, + boiler_w, + available, + target_pct, + current_pct, + new_pct, + " [capped]" if capped else "", + ) - if value is None: - return 0 - try: - return max(0, min(100, int(value))) - except (TypeError, ValueError): - return 0 + return new_pct diff --git a/custom_components/boiler_controller/calibration.py b/custom_components/boiler_controller/calibration.py deleted file mode 100644 index 251541a..0000000 --- a/custom_components/boiler_controller/calibration.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Utilities for persisting and applying per-boiler calibration data.""" -from __future__ import annotations - -from typing import Any, Dict, List, Tuple - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.storage import Store -from homeassistant.util import dt as dt_util - -from .const import CALIBRATION_STORAGE_VERSION, DOMAIN - -CalibrationProfile = Dict[str, Any] -CalibrationPoints = List[Dict[str, float | int]] - - -def _storage_key(entry_id: str) -> str: - return f"{DOMAIN}_calibration_{entry_id}" - - -def sanitize_points(points: List[Dict[str, Any]]) -> CalibrationPoints: - """Normalize raw calibration point input before storage or use.""" - sanitized: CalibrationPoints = [] - for entry in points or []: - try: - watts = float(entry.get("watts")) - percentage = int(entry.get("percentage")) - except (TypeError, ValueError): - continue - - watts = max(0.0, round(watts, 3)) - percentage = max(0, min(100, percentage)) - sanitized.append({"watts": watts, "percentage": percentage}) - - sanitized.sort(key=lambda item: (item["watts"], item["percentage"])) - - deduped: CalibrationPoints = [] - seen_watts: set[float] = set() - for item in sanitized: - watts = item["watts"] - if watts in seen_watts: - continue - seen_watts.add(watts) - deduped.append(item) - - return deduped - - -def points_to_thresholds(points: CalibrationPoints) -> List[Tuple[float, int]]: - """Convert sanitized points into calculator thresholds.""" - sanitized = sanitize_points(points) - thresholds: List[Tuple[float, int]] = [] - for item in sanitized: - thresholds.append((item["watts"], item["percentage"])) - return thresholds - - -class CalibrationStore: - """Wrapper around Home Assistant storage for calibration profiles.""" - - def __init__(self, hass: HomeAssistant, entry_id: str) -> None: - self._store = Store(hass, CALIBRATION_STORAGE_VERSION, _storage_key(entry_id)) - - async def async_load_profile(self) -> CalibrationProfile | None: - data = await self._store.async_load() - if not data: - return None - - points = sanitize_points(data.get("points", [])) - return { - "created": data.get("created"), - "points": points, - } - - async def async_save_points(self, points: CalibrationPoints) -> CalibrationProfile: - sanitized = sanitize_points(points) - payload: CalibrationProfile = { - "created": dt_util.utcnow().isoformat(), - "points": sanitized, - } - await self._store.async_save(payload) - return payload diff --git a/custom_components/boiler_controller/config_flow.py b/custom_components/boiler_controller/config_flow.py index 3ec15da..ce16ec8 100644 --- a/custom_components/boiler_controller/config_flow.py +++ b/custom_components/boiler_controller/config_flow.py @@ -1,7 +1,8 @@ +"""Config flow for the Boiler Controller integration.""" +from __future__ import annotations + import logging -from urllib.parse import urlparse -import aiohttp import voluptuous as vol from homeassistant import config_entries from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -12,487 +13,294 @@ from .const import ( DOMAIN, CONF_P1_TOTAL_ENTITY, - CONF_SHELLY_URL, - CONF_SHELLY_ID, - SHELLY_DIMMER_HOST_PREFIX, + CONF_BOILER_HOST, + CONF_BOILER_ID, + CONF_POLL_INTERVAL, + BC_HOST_PREFIX, + DEFAULT_POLL_INTERVAL, ) -from .shelly_client import ShellyClient +from .boiler_client import BoilerClient _LOGGER = logging.getLogger(__name__) -def _find_config_entry_for_device(hass, device_id: str | None, *, exclude_entry_id: str | None = None): - """Return an existing entry that already manages this Shelly.""" +def _find_config_entry_for_device( + hass, device_id: str | None, *, exclude_entry_id: str | None = None +): + """Return an existing entry that already manages this boiler controller module.""" if not device_id: return None - normalized = device_id.lower() + normalized = device_id.strip().lower() for entry in hass.config_entries.async_entries(DOMAIN): if exclude_entry_id and entry.entry_id == exclude_entry_id: continue - - entry_device_id = entry.data.get(CONF_SHELLY_ID) - if entry_device_id and entry_device_id.lower() == normalized: + entry_device_id = entry.data.get(CONF_BOILER_ID) + if entry_device_id and entry_device_id.strip().lower() == normalized: return entry - - if entry.unique_id and entry.unique_id.lower() == normalized: + if entry.unique_id and entry.unique_id.strip().lower() == normalized: return entry - return None -class ShellyValidationMixin: - """Shared helpers for validating Shelly connectivity in flows.""" - - def _normalize_url(self, url: str) -> str: - """Normalize the provided Shelly URL.""" - return url.strip().rstrip('/') if url else url - - async def _test_shelly_connection(self, url: str) -> bool: - """Test connectivity to the Shelly device.""" - try: - session = async_get_clientsession(self.hass) - test_url = f"{url}/rpc/Light.GetStatus?id=0" - async with session.get(test_url, timeout=aiohttp.ClientTimeout(total=5)) as resp: - if resp.status == 200: - return True - _LOGGER.warning("Shelly test call returned status %s", resp.status) - except aiohttp.ClientError as err: - _LOGGER.warning("Shelly connection error: %s", err) - except Exception as err: # pragma: no cover - defensive logging - _LOGGER.error("Unexpected Shelly test error: %s", err) - return False - - @staticmethod - def _decode_discovery_property(value): - """Ensure Zeroconf TXT values become plain strings.""" - if value is None: - return None - if isinstance(value, bytes): - try: - return value.decode("utf-8") - except UnicodeDecodeError: - return None - return str(value) - - @staticmethod - def _normalize_device_id(device_id: str | None) -> str | None: - """Normalize device identifiers for easier comparisons.""" - if not device_id: - return None - return str(device_id).strip().lower() - - async def _fetch_shelly_device_id(self, url: str) -> str | None: - """Retrieve the Shelly unique device identifier via RPC.""" - client = ShellyClient(self.hass, url) - payload = await client.async_get_device_info() - if not payload: - _LOGGER.warning("Shelly identity request failed for %s", url) - return self._derive_device_id_from_url(url) - - device_id = ShellyClient.extract_device_id(payload) - if device_id: - return device_id - - fallback_device_id = self._derive_device_id_from_url(url) - if fallback_device_id: - _LOGGER.debug( - "Using %s as fallback Shelly ID for %s", fallback_device_id, url - ) - return fallback_device_id - - _LOGGER.warning("Unable to extract Shelly ID from payload: %s", payload) - return None +class BoilerControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle config flow for Boiler Controller.""" - @staticmethod - def _derive_device_id_from_url(url: str) -> str | None: - """Fallback to a stable identifier derived from the URL host/port.""" - if not url: - return None + VERSION = 1 - try: - parsed = urlparse(url) - except ValueError: - return None + def __init__(self) -> None: + self.data: dict = {} - host = parsed.hostname - if not host: - return None - - host = host.strip().lower().replace(":", "-") - if parsed.port: - host = f"{host}-{parsed.port}" - - return host or None - - -class BoilerControllerConfigFlow(ShellyValidationMixin, config_entries.ConfigFlow, domain=DOMAIN): - VERSION = 4 - - def __init__(self): - self.data = {} + # ------------------------------------------------------------------ + # Zeroconf auto-discovery + # ------------------------------------------------------------------ async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo): - """Handle Zeroconf discovery for Shelly dimmers.""" - hostname = discovery_info.hostname or discovery_info.name - if not hostname: - return self.async_abort(reason="unsupported_device") + """Handle Zeroconf discovery for boiler-controller-.local devices.""" + hostname = (discovery_info.hostname or discovery_info.name or "").rstrip(".") + short_hostname = hostname.split(".")[0].lower() - hostname = hostname.rstrip('.') - short_hostname = hostname.split('.')[0].lower() - if not short_hostname.startswith(SHELLY_DIMMER_HOST_PREFIX): + if not short_hostname.startswith(BC_HOST_PREFIX): return self.async_abort(reason="unsupported_device") - properties = discovery_info.properties or {} - device_id = self._normalize_device_id(self._decode_discovery_property(properties.get("id"))) - host_property = self._decode_discovery_property(properties.get("host")) - mdns_host = (host_property or hostname).rstrip('.') - ip_address = str(discovery_info.host) if discovery_info.host else None + # Derive unique ID from the UUID part of the hostname + uuid_part = short_hostname[len(BC_HOST_PREFIX):] + unique_id = uuid_part or short_hostname - if mdns_host.startswith("http://") or mdns_host.startswith("https://"): - shelly_url = self._normalize_url(mdns_host) - else: - shelly_url = f"http://{mdns_host}" - - unique_id = device_id or short_hostname + # Build host (prefer IP address from discovery for reliability) + ip_address = str(discovery_info.host) if discovery_info.host else None + boiler_host = ip_address or hostname - existing_entry = _find_config_entry_for_device(self.hass, unique_id) - if existing_entry: + existing = _find_config_entry_for_device(self.hass, unique_id) + if existing: return self.async_abort(reason="already_configured") await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured(updates={CONF_SHELLY_URL: shelly_url}) + self._abort_if_unique_id_configured( + updates={CONF_BOILER_HOST: boiler_host} + ) - self.data[CONF_SHELLY_URL] = shelly_url - self.data[CONF_SHELLY_ID] = unique_id - self.context["title_placeholders"] = {"device": mdns_host} + self.data[CONF_BOILER_HOST] = boiler_host + self.data[CONF_BOILER_ID] = unique_id + self.context["title_placeholders"] = {"device": short_hostname} return await self.async_step_user() + # ------------------------------------------------------------------ + # Manual setup steps + # ------------------------------------------------------------------ + async def async_step_user(self, user_input=None): - """Handle the initial step.""" - _LOGGER.debug("Boiler Controller config flow started") - - errors = {} + """Step 1 – integration name.""" + errors: dict = {} if user_input is not None: - # Store user input and proceed to power sensor selection self.data.update(user_input) return await self.async_step_power_sensor() - schema = vol.Schema({ - vol.Required("name", default="Boiler Controller"): str, - }) - return self.async_show_form( - step_id="user", - data_schema=schema, - errors=errors + step_id="user", + data_schema=vol.Schema( + {vol.Required("name", default="Boiler Controller"): str} + ), + errors=errors, ) async def async_step_power_sensor(self, user_input=None): - """Handle power sensor selection.""" - errors = {} + """Step 2 – select the P1 power sensor entity.""" + errors: dict = {} if user_input is not None: - # Store power sensor selection self.data.update(user_input) - return await self.async_step_shelly_config() + return await self.async_step_boiler_config() - # Get power sensors power_sensors = await self._get_power_sensors() - if not power_sensors: return self.async_abort(reason="no_power_sensors") - schema = vol.Schema({ - vol.Required(CONF_P1_TOTAL_ENTITY): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - {"value": key, "label": value} - for key, value in power_sensors.items() - ], - mode=selector.SelectSelectorMode.DROPDOWN - ) - ), - }) - return self.async_show_form( - step_id="power_sensor", - data_schema=schema, - errors=errors + step_id="power_sensor", + data_schema=vol.Schema( + { + vol.Required(CONF_P1_TOTAL_ENTITY): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + {"value": k, "label": v} + for k, v in power_sensors.items() + ], + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ) + } + ), + errors=errors, ) - async def async_step_shelly_config(self, user_input=None): - """Handle Shelly connection configuration.""" - errors = {} + async def async_step_boiler_config(self, user_input=None): + """Step 3 – boiler module host/IP and optional settings.""" + errors: dict = {} - stored_url = self.data.get(CONF_SHELLY_URL) - default_url = self._normalize_url(stored_url) if stored_url else "" + stored_host = self.data.get(CONF_BOILER_HOST, "") if user_input is not None: - shelly_url = self._normalize_url(user_input.get(CONF_SHELLY_URL, "")) - - if not shelly_url.startswith(("http://", "https://")): - errors[CONF_SHELLY_URL] = "invalid_url" + host = user_input.get(CONF_BOILER_HOST, "").strip() + + # Basic validation: must not be empty + if not host: + errors[CONF_BOILER_HOST] = "invalid_host" else: - # Test Shelly endpoint connectivity - if await self._test_shelly_connection(shelly_url): - device_id = await self._fetch_shelly_device_id(shelly_url) - if not device_id: - errors[CONF_SHELLY_URL] = "cannot_identify" - else: - existing_entry = _find_config_entry_for_device(self.hass, device_id) - if existing_entry: - return self.async_abort(reason="already_configured") - - if self.unique_id is None: - await self.async_set_unique_id(device_id) - - self.data.update({ - CONF_SHELLY_URL: shelly_url, - CONF_SHELLY_ID: device_id, - }) - - return self.async_create_entry( - title=self.data.get("name", "Boiler Controller"), - data=self.data - ) - else: - errors[CONF_SHELLY_URL] = "cannot_connect" + client = BoilerClient(self.hass, host) + if await client.async_test_connection(): + device_id = self.data.get(CONF_BOILER_ID) or host + + existing = _find_config_entry_for_device(self.hass, device_id) + if existing: + return self.async_abort(reason="already_configured") + + if self.unique_id is None: + await self.async_set_unique_id(device_id) + + self.data.update( + { + CONF_BOILER_HOST: host, + CONF_BOILER_ID: device_id, + CONF_POLL_INTERVAL: DEFAULT_POLL_INTERVAL, + } + ) - default_url = shelly_url + return self.async_create_entry( + title=self.data.get("name", "Boiler Controller"), + data=self.data, + ) + else: + errors[CONF_BOILER_HOST] = "cannot_connect" - schema = vol.Schema({ - vol.Required(CONF_SHELLY_URL, default=default_url): str - }) + stored_host = host return self.async_show_form( - step_id="shelly_config", - data_schema=schema, + step_id="boiler_config", + data_schema=vol.Schema( + { + vol.Required(CONF_BOILER_HOST, default=stored_host): str, + } + ), errors=errors, description_placeholders={ - "example_url1": "http://shelly0110dimg3-xxxx.local", - "example_url2": "http://shellyplusdimg3-xxxx.local" - } + "example_host": "192.168.1.100 or boiler-controller-abcd1234.local" + }, ) - async def _get_power_sensors(self): - """Get list of power sensors.""" - sensors = {} - - for entity_id in self.hass.states.async_entity_ids('sensor'): + # ------------------------------------------------------------------ + # Options flow + # ------------------------------------------------------------------ + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return BoilerControllerOptionsFlow(config_entry) + + # ------------------------------------------------------------------ + # Helper + # ------------------------------------------------------------------ + + async def _get_power_sensors(self) -> dict: + """Return a {entity_id: label} dict of plausible power sensors.""" + sensors: dict = {} + for entity_id in self.hass.states.async_entity_ids("sensor"): state = self.hass.states.get(entity_id) if not state: continue - - # Look for power-related sensors - if any(keyword in entity_id.lower() for keyword in [ - 'power', 'watt', 'electricity', 'current_consumption', 'current_production', - 'energy', 'verbruik', 'opwek', 'net_power' - ]): - # Check if it has a numeric state and power-related unit + if any( + kw in entity_id.lower() + for kw in [ + "power", + "watt", + "electricity", + "verbruik", + "opwek", + "net_power", + "energy", + ] + ): try: float(state.state) - unit = state.attributes.get('unit_of_measurement', '') - if any(u in unit.lower() for u in ['w', 'kw', 'watt']): - friendly_name = state.attributes.get('friendly_name', entity_id) - sensors[entity_id] = f"{friendly_name} ({entity_id}) [{unit}]" + unit = state.attributes.get("unit_of_measurement", "") + if any(u in (unit or "").lower() for u in ["w", "kw", "watt"]): + label = state.attributes.get("friendly_name", entity_id) + sensors[entity_id] = f"{label} ({entity_id}) [{unit}]" except (ValueError, TypeError): - continue - + pass return sensors - @staticmethod - @callback - def async_get_options_flow(config_entry): - return BoilerControllerOptionsFlow(config_entry) - -class BoilerControllerOptionsFlow(ShellyValidationMixin, config_entries.OptionsFlow): - """Handle options flow for Boiler Controller.""" +class BoilerControllerOptionsFlow(config_entries.OptionsFlow): + """Options flow to update host, poll interval and max watts.""" - def __init__(self, config_entry): - super().__init__() - self._config_entry = config_entry - self.data = {} + def __init__(self, config_entry) -> None: + self.config_entry = config_entry async def async_step_init(self, user_input=None): - """Manage the options.""" - if user_input is not None: - if user_input.get("change_devices"): - return await self.async_step_power_sensor() - return self.async_create_entry(title="", data={}) - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema({ - vol.Optional("change_devices", default=False): bool, - }) - ) + """Manage options.""" + errors: dict = {} + data = self.config_entry.data + opts = self.config_entry.options - async def async_step_power_sensor(self, user_input=None): - """Handle power sensor change.""" if user_input is not None: - self.data.update(user_input) - return await self.async_step_shelly_config() + host = user_input.get(CONF_BOILER_HOST, "").strip() + if not host: + errors[CONF_BOILER_HOST] = "invalid_host" + else: + client = BoilerClient(self.hass, host) + if await client.async_test_connection(): + return self.async_create_entry(title="", data=user_input) + errors[CONF_BOILER_HOST] = "cannot_connect" + + current_host = opts.get(CONF_BOILER_HOST, data.get(CONF_BOILER_HOST, "")) + current_power_sensor = opts.get( + CONF_P1_TOTAL_ENTITY, data.get(CONF_P1_TOTAL_ENTITY, "") + ) - # Get power sensors power_sensors = await self._get_power_sensors() - - if not power_sensors: - return self.async_abort(reason="no_power_sensors") - # Get current selection - current_power_sensor = self._config_entry.data.get(CONF_P1_TOTAL_ENTITY) - - schema = vol.Schema({ - vol.Required(CONF_P1_TOTAL_ENTITY, default=current_power_sensor): selector.SelectSelector( + schema_dict: dict = {} + if power_sensors: + schema_dict[ + vol.Optional(CONF_P1_TOTAL_ENTITY, default=current_power_sensor) + ] = selector.SelectSelector( selector.SelectSelectorConfig( - options=[ - {"value": key, "label": value} - for key, value in power_sensors.items() - ], - mode=selector.SelectSelectorMode.DROPDOWN + options=[{"value": k, "label": v} for k, v in power_sensors.items()], + mode=selector.SelectSelectorMode.DROPDOWN, ) - ), - }) + ) - return self.async_show_form( - step_id="power_sensor", - data_schema=schema + schema_dict.update( + { + vol.Required(CONF_BOILER_HOST, default=current_host): str, + } ) - async def async_step_shelly_config(self, user_input=None): - """Handle Shelly configuration updates.""" - errors = {} - - stored_url = self._config_entry.data.get(CONF_SHELLY_URL) - current_url = self._normalize_url(stored_url) if stored_url else "" - - if user_input is not None: - shelly_url = self._normalize_url(user_input.get(CONF_SHELLY_URL, current_url)) - - if not shelly_url.startswith(("http://", "https://")): - errors[CONF_SHELLY_URL] = "invalid_url" - else: - device_id = None - if not await self._test_shelly_connection(shelly_url): - errors[CONF_SHELLY_URL] = "cannot_connect" - else: - device_id = await self._fetch_shelly_device_id(shelly_url) - if not device_id: - errors[CONF_SHELLY_URL] = "cannot_identify" - - if not errors: - existing_entry = _find_config_entry_for_device( - self.hass, - device_id, - exclude_entry_id=self._config_entry.entry_id, - ) - if existing_entry: - errors[CONF_SHELLY_URL] = "device_in_use" - else: - # Update stored data/options - new_data = dict(self._config_entry.data) - if self.data: - new_data.update(self.data) - new_data[CONF_SHELLY_URL] = shelly_url - if device_id: - new_data[CONF_SHELLY_ID] = device_id - - new_options = dict(self._config_entry.options) - - unique_id = device_id or self._config_entry.unique_id - - self.hass.config_entries.async_update_entry( - self._config_entry, - data=new_data, - options=new_options, - unique_id=unique_id, - ) - - await self.hass.config_entries.async_reload(self._config_entry.entry_id) - return self.async_create_entry(title="", data={}) - - current_url = shelly_url - schema = vol.Schema({ - vol.Required(CONF_SHELLY_URL, default=current_url): str - }) - return self.async_show_form( - step_id="shelly_config", - data_schema=schema, + step_id="init", + data_schema=vol.Schema(schema_dict), errors=errors, - description_placeholders={ - "example_url1": "http://shelly0110dimg3-xxxx.local", - "example_url2": "http://shellyplusdimg3-xxxx.local" - } ) - async def _get_power_sensors(self): - """Get list of power sensors.""" - sensors = {} - - for entity_id in self.hass.states.async_entity_ids('sensor'): + async def _get_power_sensors(self) -> dict: + sensors: dict = {} + for entity_id in self.hass.states.async_entity_ids("sensor"): state = self.hass.states.get(entity_id) if not state: continue - - # Look for power-related sensors - if any(keyword in entity_id.lower() for keyword in [ - 'power', 'watt', 'electricity', 'current_consumption', 'current_production', - 'energy', 'verbruik', 'opwek', 'net_power' - ]): - # Check if it has a numeric state and power-related unit + if any( + kw in entity_id.lower() + for kw in ["power", "watt", "electricity", "verbruik", "opwek", "net_power", "energy"] + ): try: float(state.state) - unit = state.attributes.get('unit_of_measurement', '') - if any(u in unit.lower() for u in ['w', 'kw', 'watt']): - friendly_name = state.attributes.get('friendly_name', entity_id) - sensors[entity_id] = f"{friendly_name} ({entity_id}) [{unit}]" + unit = state.attributes.get("unit_of_measurement", "") + if any(u in (unit or "").lower() for u in ["w", "kw", "watt"]): + label = state.attributes.get("friendly_name", entity_id) + sensors[entity_id] = f"{label} ({entity_id}) [{unit}]" except (ValueError, TypeError): - continue - + pass return sensors - - async def _get_light_entities(self): - """Get list of light entities and input_number entities (including dimmers).""" - entities = {} - - # Get light entities (prioritize dimmable lights) - dimmable_lights = {} - onoff_lights = {} - - for entity_id in self.hass.states.async_entity_ids('light'): - state = self.hass.states.get(entity_id) - if not state: - continue - - friendly_name = state.attributes.get('friendly_name', entity_id) - - # Check if it supports brightness (dimming) - supported_features = state.attributes.get('supported_features', 0) - if supported_features & 1: # SUPPORT_BRIGHTNESS = 1 - dimmable_lights[entity_id] = f"{friendly_name} ({entity_id}) [Dimmable Light]" - else: - onoff_lights[entity_id] = f"{friendly_name} ({entity_id}) [On/Off Light]" - - # Get input_number entities (can be used as dimmers) - input_numbers = {} - for entity_id in self.hass.states.async_entity_ids('input_number'): - state = self.hass.states.get(entity_id) - if not state: - continue - - friendly_name = state.attributes.get('friendly_name', entity_id) - min_val = state.attributes.get('min', 0) - max_val = state.attributes.get('max', 100) - input_numbers[entity_id] = f"{friendly_name} ({entity_id}) [Number: {min_val}-{max_val}]" - - # Combine in order: input_numbers, dimmable lights, then on/off lights - entities.update(input_numbers) - entities.update(dimmable_lights) - entities.update(onoff_lights) - - return entities diff --git a/custom_components/boiler_controller/const.py b/custom_components/boiler_controller/const.py index e92b95e..6b3bc0d 100644 --- a/custom_components/boiler_controller/const.py +++ b/custom_components/boiler_controller/const.py @@ -1,53 +1,34 @@ DOMAIN = "boiler_controller" -VERSION = "1.0.0" +VERSION = "2.0.0" -PLATFORMS = ["sensor", "select", "number", "button"] - -# Configuration flow step IDs -STEP_POWER_SENSOR = "power_sensor" -STEP_SHELLY_CONFIG = "shelly_config" +PLATFORMS = ["sensor", "select", "number"] # Configuration keys -CONF_P1_TOTAL_ENTITY = "power_sensor" # Renamed to be more generic -CONF_SHELLY_URL = "shelly_url" -CONF_SHELLY_POLL_INTERVAL = "shelly_poll_interval" -CONF_SHELLY_ID = "shelly_id" - -# Shelly RPC endpoints -SHELLY_RPC_DEVICE_INFO = "/rpc/Shelly.GetDeviceInfo" -SHELLY_RPC_LIGHT_STATUS = "/rpc/Light.GetStatus" -SHELLY_RPC_LIGHT_SET = "/rpc/Light.Set" -SHELLY_RPC_LIGHT_CONFIG = "/rpc/Light.GetConfig" +CONF_P1_TOTAL_ENTITY = "power_sensor" +CONF_BOILER_HOST = "boiler_host" +CONF_BOILER_ID = "boiler_id" +CONF_POLL_INTERVAL = "poll_interval" -# Shelly Dimmer 0/1-10V Gen3 units report either the legacy (0110) hostname prefix -# or the newer "plus" prefix depending on firmware/model. Accept both so discovery -# works for every variant. -SHELLY_DIMMER_HOST_PREFIX = ("shelly0110dimg3-", "shellyplusdimg3-") +# mDNS discovery prefix for the boiler controller module +BC_HOST_PREFIX = "boiler-controller-" # Default settings for the controller -DEFAULT_MIN_DIMMER_VALUE = 0 -DEFAULT_MAX_DIMMER_VALUE = 100 -# Manual override defaults and modes -MIN_MANUAL_BRIGHTNESS = 20 -DEFAULT_MANUAL_BRIGHTNESS = MIN_MANUAL_BRIGHTNESS -DIMMER_MODE_AUTO = "auto" -DIMMER_MODE_MANUAL = "manual" -DIMMER_MODES = [DIMMER_MODE_AUTO, DIMMER_MODE_MANUAL] -# Minimum spacing between calculator-driven dimmer updates -DEFAULT_CALCULATOR_MIN_INTERVAL = 15 -# Seconds between Shelly status polls -# Where it updates all the sensor values -DEFAULT_SHELLY_POLL_INTERVAL = 15 - -# Calibration service/options -SERVICE_RUN_CALIBRATION = "run_calibration" -SERVICE_CANCEL_CALIBRATION = "cancel_calibration" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" -CALIBRATION_START_PERCENTAGE = 20 -CALIBRATION_END_PERCENTAGE = 100 -CALIBRATION_STEP_PERCENTAGE = 1 -CALIBRATION_SETTLE_SECONDS = 3 -CALIBRATION_STORAGE_VERSION = 1 -# Safety limits -MAX_EXPORT_WATTS = 2200 \ No newline at end of file +# Default polling interval in seconds for fetching power +# data and updating boiler control sensors +DEFAULT_POLL_INTERVAL = 15 +DEFAULT_MAX_BOILER_WATTS = 2200 +# Maximum heating percentage step per auto-control cycle (for gradual ramping) +DEFAULT_MAX_STEP_PERCENTAGE = 10 +# Minimum interval between auto-control updates (seconds) +# This is a safety measure to prevent too frequent updates in case of rapid grid power fluctuations. +DEFAULT_CONTROLLER_MIN_INTERVAL = 15 + +# Control modes +CONTROL_MODE_AUTO = "auto" +CONTROL_MODE_MANUAL = "manual" +CONTROL_MODES = [CONTROL_MODE_AUTO, CONTROL_MODE_MANUAL] + +# Manual heating percentage bounds +MIN_MANUAL_PERCENTAGE = 0 +DEFAULT_MANUAL_PERCENTAGE = 0 \ No newline at end of file diff --git a/custom_components/boiler_controller/controller.py b/custom_components/boiler_controller/controller.py index 5e0dff8..4f061d7 100644 --- a/custom_components/boiler_controller/controller.py +++ b/custom_components/boiler_controller/controller.py @@ -1,5 +1,9 @@ -import logging +"""Controller for the Boiler Controller integration.""" +from __future__ import annotations + import asyncio +import logging +from typing import Any, Dict, Optional from homeassistant.core import HomeAssistant, Event, callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -9,835 +13,419 @@ from .const import ( DOMAIN, CONF_P1_TOTAL_ENTITY, - CONF_SHELLY_URL, - CONF_SHELLY_ID, - CONF_SHELLY_POLL_INTERVAL, - DEFAULT_MIN_DIMMER_VALUE, - DEFAULT_MAX_DIMMER_VALUE, - DEFAULT_CALCULATOR_MIN_INTERVAL, - DEFAULT_SHELLY_POLL_INTERVAL, - DEFAULT_MANUAL_BRIGHTNESS, - MIN_MANUAL_BRIGHTNESS, - DIMMER_MODE_AUTO, - DIMMER_MODE_MANUAL, - DIMMER_MODES, + CONF_BOILER_HOST, + CONF_POLL_INTERVAL, + DEFAULT_POLL_INTERVAL, + DEFAULT_MAX_BOILER_WATTS, + DEFAULT_MAX_STEP_PERCENTAGE, + DEFAULT_CONTROLLER_MIN_INTERVAL, + DEFAULT_MANUAL_PERCENTAGE, + MIN_MANUAL_PERCENTAGE, + CONTROL_MODE_AUTO, + CONTROL_MODE_MANUAL, + CONTROL_MODES, ) -from .shelly_client import ShellyClient +from .boiler_client import BoilerClient from .calculator import Calculator -from .calibration import CalibrationStore, points_to_thresholds _LOGGER = logging.getLogger(__name__) class BoilerController: - """Controller for managing boiler based on P1 data.""" - - def __init__(self, hass: HomeAssistant, config_entry, integration_version: str | None): + """Controller for managing a boiler based on P1 surplus power.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry, + integration_version: str | None, + ) -> None: self.hass = hass self.config_entry = config_entry self.integration_version = integration_version + + # Dispatcher signals for entity updates + self._status_signal = f"{DOMAIN}_{config_entry.entry_id}_status" + self._mode_signal = f"{DOMAIN}_{config_entry.entry_id}_control_mode" + self._manual_pct_signal = f"{DOMAIN}_{config_entry.entry_id}_manual_pct" + + # Internal state self._cancel_listener = None - self._poll_task = None - self._polling_suspended = False - self._last_dimmer_update = None - self._last_power_value = None - self._last_calculator_run = None - self._shelly_status = None - self._current_dimmer_percentage: int | None = None - self._dispatcher_signal = f"{DOMAIN}_{config_entry.entry_id}_shelly_status" - self._mode_signal = f"{DOMAIN}_{config_entry.entry_id}_dimming_mode" - self._manual_brightness_signal = f"{DOMAIN}_{config_entry.entry_id}_manual_brightness" - self._calibration_signal = f"{DOMAIN}_{config_entry.entry_id}_calibration_state" - + self._poll_task: asyncio.Task | None = None + self._last_control_run: Any = None + self._last_power_value: float | None = None + self._module_status: Dict[str, Any] | None = None + self._module_system: Dict[str, Any] | None = None + self._last_update: Any = None + # Configuration - self.shelly_url = config_entry.data[CONF_SHELLY_URL] - self.power_sensor_id = config_entry.data[CONF_P1_TOTAL_ENTITY] - self.shelly_client = ShellyClient(hass, self.shelly_url) - self._calculator = Calculator() - stored_mode = config_entry.options.get("dimming_mode", DIMMER_MODE_MANUAL) - self._dimming_mode = stored_mode if stored_mode in DIMMER_MODES else DIMMER_MODE_MANUAL - stored_manual = config_entry.options.get("manual_brightness", DEFAULT_MANUAL_BRIGHTNESS) - self._manual_brightness = max(MIN_MANUAL_BRIGHTNESS, min(100, int(stored_manual))) - self._calibration_store = CalibrationStore(hass, config_entry.entry_id) - self._calibration_profile: dict | None = None - self._calibration_lock = asyncio.Lock() - self._calibration_active = False - self._calibration_cancel_requested = False - # To restore mode after calibration - self._calibration_previous_mode: str | None = None - - # Options - self.min_dimmer_value = DEFAULT_MIN_DIMMER_VALUE - self.max_dimmer_value = DEFAULT_MAX_DIMMER_VALUE - self._device_min_dimmer_value: int | None = None - self._device_max_dimmer_value: int | None = None - self._effective_min_dimmer_value = self.min_dimmer_value - self._effective_max_dimmer_value = self.max_dimmer_value - self.shelly_poll_interval = config_entry.options.get( - CONF_SHELLY_POLL_INTERVAL, - config_entry.data.get(CONF_SHELLY_POLL_INTERVAL, DEFAULT_SHELLY_POLL_INTERVAL) + self.power_sensor_id: str = config_entry.data[CONF_P1_TOTAL_ENTITY] + boiler_host: str = config_entry.data[CONF_BOILER_HOST] + self.boiler_client = BoilerClient(hass, boiler_host) + + self.poll_interval: int = int( + config_entry.options.get( + CONF_POLL_INTERVAL, + config_entry.data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL), + ) + ) + self.max_boiler_watts: float = float(DEFAULT_MAX_BOILER_WATTS) + self._calculator = Calculator( + max_boiler_watts=self.max_boiler_watts, + max_step=DEFAULT_MAX_STEP_PERCENTAGE, + ) + + # Control mode (auto / manual) + stored_mode = config_entry.options.get("control_mode", CONTROL_MODE_AUTO) + self._control_mode: str = ( + stored_mode if stored_mode in CONTROL_MODES else CONTROL_MODE_AUTO + ) + stored_manual = config_entry.options.get( + "manual_percentage", DEFAULT_MANUAL_PERCENTAGE ) - - self._recompute_effective_dimmer_bounds() - + self._manual_percentage: int = max( + MIN_MANUAL_PERCENTAGE, min(100, int(stored_manual)) + ) + _LOGGER.debug( - "Initialized BoilerController: Power Sensor=%s, Shelly URL=%s, throttle_interval=%ds, poll_interval=%ds", + "Initialized BoilerController: power_sensor=%s, boiler_host=%s, " + "poll_interval=%ds, max_boiler_watts=%.0fW", self.power_sensor_id, - self.shelly_url, - DEFAULT_CALCULATOR_MIN_INTERVAL, - self.shelly_poll_interval, + boiler_host, + self.poll_interval, + self.max_boiler_watts, ) - async def async_start(self): + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def async_start(self) -> bool: """Start the controller.""" _LOGGER.info("Starting Boiler Controller") - - # Validate entities exist (informational only, always continue) + await self._validate_configuration() - # Test Shelly connection once at startup - if await self.shelly_client.async_test_connection(): - _LOGGER.info("Shelly device reachable at %s", self.shelly_url) - await self._async_sync_shelly_dimmer_constraints() - await self._ensure_device_identity() + if await self.boiler_client.async_test_connection(): + _LOGGER.info("Boiler module reachable at %s", self.boiler_client.host) else: - _LOGGER.warning("Unable to reach Shelly device at %s during startup", self.shelly_url) - - await self._async_load_calibration_profile() + _LOGGER.warning( + "Unable to reach boiler module at %s during startup", + self.boiler_client.host, + ) - # Start listening to power sensor state changes + # Track power sensor changes for event-driven control self._cancel_listener = async_track_state_change_event( self.hass, [self.power_sensor_id], - self._async_power_sensor_changed + self._async_power_sensor_changed, ) - _LOGGER.info("Started listening to power sensor state changes for: %s", self.power_sensor_id) + _LOGGER.info("Listening to power sensor: %s", self.power_sensor_id) + + # Start polling task for module telemetry + self._poll_task = self.hass.loop.create_task(self._async_poll_module()) + _LOGGER.info("Started module polling task (interval %ss)", self.poll_interval) - # Start Shelly polling task - self._poll_task = self.hass.loop.create_task(self._async_poll_shelly()) - _LOGGER.info("Started Shelly polling task with interval %ss", self.shelly_poll_interval) - - # Run initial update (will fail gracefully if entities don't exist yet) + # Initial control update await self._async_update() - + _LOGGER.info("Boiler Controller started successfully") return True + async def async_stop(self) -> None: + """Stop the controller and release resources.""" + _LOGGER.info("Stopping Boiler Controller") + if self._cancel_listener: + self._cancel_listener() + self._cancel_listener = None + if self._poll_task: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + + # ------------------------------------------------------------------ + # Power sensor callback + # ------------------------------------------------------------------ + @callback - async def _async_power_sensor_changed(self, event: Event): + async def _async_power_sensor_changed(self, event: Event) -> None: """Handle power sensor state changes.""" - if self._calibration_active: - _LOGGER.debug("Skipping power sensor update while calibration is active") - return - now = dt_util.utcnow() - if self._last_calculator_run is not None: - elapsed = (now - self._last_calculator_run).total_seconds() - if elapsed < DEFAULT_CALCULATOR_MIN_INTERVAL: + if self._last_control_run is not None: + elapsed = (now - self._last_control_run).total_seconds() + if elapsed < DEFAULT_CONTROLLER_MIN_INTERVAL: return new_state = event.data.get("new_state") old_state = event.data.get("old_state") - if self._dimming_mode != DIMMER_MODE_AUTO: - _LOGGER.debug( - "Ignoring power sensor event while in manual mode: %s -> %s", - old_state.state if old_state else "None", - new_state.state if new_state else "None", - ) + if self._control_mode != CONTROL_MODE_AUTO: return - if not new_state: + if not new_state or new_state.state in ("unknown", "unavailable", "none"): return - - # Skip if state hasn't actually changed or is unavailable - if (old_state and new_state.state == old_state.state) or new_state.state in ("unknown", "unavailable", "none"): - _LOGGER.debug("Skipping update - state unchanged or unavailable") + + if old_state and new_state.state == old_state.state: return - - # Parse and validate the new power value first + try: - raw_power_value = float(new_state.state) + raw = float(new_state.state) except (ValueError, TypeError): _LOGGER.warning("Invalid power sensor value: %s", new_state.state) return - unit = self._get_state_unit(new_state) - new_power_value = self._normalize_power_unit(raw_power_value, unit) - - # Only update if power value actually changed significantly (more than 1W difference) - # TODO: Consider making this threshold configurable as the controller needs more than 200 watt to perform well - if self._last_power_value is not None and abs(new_power_value - self._last_power_value) < 1: - _LOGGER.debug("Skipping update - power change too small: %.1fW", abs(new_power_value - self._last_power_value)) - return - - # Store the new power value - self._last_power_value = new_power_value - - _LOGGER.debug( - "Power sensor changed from %s %s to %.3f W (processing update)", - old_state.state if old_state else "unknown", - unit or "W", - new_power_value, - ) - - # Update the controller with new power value - await self._async_update() + unit = new_state.attributes.get("unit_of_measurement", "") + power_w = self._normalize_power_unit(raw, unit) - async def async_stop(self): - """Stop the controller.""" - _LOGGER.info("Stopping Boiler Controller") - if self._cancel_listener: - self._cancel_listener() - self._cancel_listener = None - if self._poll_task: - self._poll_task.cancel() - try: - await self._poll_task - except asyncio.CancelledError: - pass - self._poll_task = None + if ( + self._last_power_value is not None + and abs(power_w - self._last_power_value) < 1 + ): + return - async def _validate_configuration(self) -> bool: - """Validate that all configured entities exist.""" - - # Check power sensor exists (informational only, don't block startup) - power_state = self.hass.states.get(self.power_sensor_id) - if not power_state: - _LOGGER.info("Power sensor %s not found yet - controller will start and wait for entity", self.power_sensor_id) - else: - _LOGGER.info("Found power sensor: %s (current value: %s)", self.power_sensor_id, power_state.state) - - _LOGGER.info( - "Controller configured with power sensor: %s, Shelly URL: %s", - self.power_sensor_id, - self.shelly_url, - ) - - return True + self._last_power_value = power_w + _LOGGER.debug("Power sensor changed: %.1f W", power_w) + await self._async_update() - async def _async_update(self, *args): - """Update the controller - read P1 data and adjust dimmer.""" - if self._calibration_active: - _LOGGER.debug("Calibration active - skipping automatic adjustment") - return + # ------------------------------------------------------------------ + # Main control loop + # ------------------------------------------------------------------ + async def _async_update(self, *_: Any) -> None: + """Read P1 data and adjust boiler heating percentage when in auto mode.""" try: - # Get current power consumption/production from P1 power_value = await self._get_p1_power_value() - if power_value is None: - _LOGGER.debug("Could not read P1 power value - sensor may not be ready yet") - return - - # Store the current power value - self._last_power_value = power_value - _LOGGER.debug("Current P1 power value: %s W", power_value) + if power_value is not None: + self._last_power_value = power_value - if self._dimming_mode == DIMMER_MODE_MANUAL: - _LOGGER.debug("Manual dimmer mode active - skipping automatic adjustment") + if self._control_mode == CONTROL_MODE_MANUAL: return - - # Calculate dimmer percentage based on power value - dimmer_percentage = self._calculator.calculate( - power_value, - self._effective_min_dimmer_value, - self._effective_max_dimmer_value, - boiler_consumption=self._extract_boiler_consumption(), - current_dimmer=self._extract_boiler_brightness(), - ) - - # Update dimmer - await self._set_dimmer_percentage(dimmer_percentage, source=DIMMER_MODE_AUTO) - - timestamp = dt_util.utcnow() - self._last_calculator_run = timestamp - self._last_dimmer_update = timestamp - - except Exception as err: - _LOGGER.error("Error during controller update: %s", err) - async def _get_p1_power_value(self) -> float | None: - """Get current power value from power sensor entity.""" - try: - state = self.hass.states.get(self.power_sensor_id) - if not state: - # Only show this error occasionally, not every time - now = dt_util.utcnow() - if not hasattr(self, '_last_missing_sensor_log') or \ - (now - self._last_missing_sensor_log).total_seconds() > 60: - _LOGGER.warning("Power sensor %s not found - check if entity exists", self.power_sensor_id) - self._last_missing_sensor_log = now - return None - - if state.state in ("unknown", "unavailable", "none"): - _LOGGER.debug("Power sensor %s is unavailable (state: %s)", self.power_sensor_id, state.state) - return None - - # Convert state to float and normalize units - power_value = float(state.state) - unit = self._get_state_unit(state) - power_value = self._normalize_power_unit(power_value, unit) - # Clear the missing sensor log timer since we got data - if hasattr(self, '_last_missing_sensor_log'): - delattr(self, '_last_missing_sensor_log') - return power_value - - except (ValueError, TypeError) as err: - _LOGGER.warning("Error parsing power sensor value '%s': %s", state.state if state else "None", err) - return None - - async def _async_poll_shelly(self): - """Poll Shelly status at the configured interval.""" - while True: - try: - if not self._polling_suspended: - status = await self.shelly_client.async_get_status() - if status is not None: - self._shelly_status = status - self._update_cached_brightness(status) - async_dispatcher_send(self.hass, self._dispatcher_signal, status) - await asyncio.sleep(self.shelly_poll_interval) - except asyncio.CancelledError: - _LOGGER.debug("Shelly polling task cancelled") - break - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Unexpected Shelly polling error: %s", err) - await asyncio.sleep(self.shelly_poll_interval) - - async def _async_refresh_shelly_status(self): - """Force a Shelly status refresh outside the poll loop.""" - try: - status = await self.shelly_client.async_get_status() - if status is None: + if power_value is None: + _LOGGER.debug("No P1 value available – skipping auto control") return - self._shelly_status = status - self._update_cached_brightness(status) - async_dispatcher_send(self.hass, self._dispatcher_signal, status) - except Exception as err: # pylint: disable=broad-except - _LOGGER.debug("Manual Shelly status refresh failed: %s", err) - - async def _set_dimmer_percentage(self, percentage: int, *, source: str = DIMMER_MODE_AUTO): - """Set the dimmer to the specified percentage using Shelly API.""" - try: - if source == DIMMER_MODE_MANUAL: - context = "manual override" - elif source == "calibration": - context = "calibration" - else: - context = "auto calculation" - if percentage <= 0: - _LOGGER.info("Shelly dimmer request (%s): turn off (requested %s%%)", context, percentage) - set_success = await self.shelly_client.async_set_brightness(0) - if not set_success: - _LOGGER.warning("Failed to set Shelly dimmer to 0%% before turn off") - success = await self.shelly_client.async_turn_off() - self._current_dimmer_percentage = 0 - if success: - _LOGGER.debug("Shelly dimmer turned off") - else: - _LOGGER.warning("Failed to turn off Shelly dimmer") - _LOGGER.info("Shelly dimmer turn_off success=%s", success) - else: - desired_percentage = max(1, percentage) - if source == DIMMER_MODE_MANUAL: - _LOGGER.info( - "Shelly dimmer request (%s): set to %s%%", - context, - desired_percentage, - ) - else: - _LOGGER.info( - "Shelly dimmer request (%s): set to %s%% (effective range %s-%s%%)", - context, - desired_percentage, - self._effective_min_dimmer_value, - self._effective_max_dimmer_value, - ) - success = await self.shelly_client.async_set_brightness(desired_percentage) - self._current_dimmer_percentage = desired_percentage - if success: - _LOGGER.debug("Shelly dimmer set to %s%%", desired_percentage) - else: - _LOGGER.warning("Failed to set Shelly dimmer to %s%%", desired_percentage) - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Error setting Shelly dimmer percentage: %s", err) - - @property - def device_info(self): - """Return device information.""" - from .const import VERSION as DEFAULT_VERSION - version = self.integration_version if self.integration_version else DEFAULT_VERSION - return { - "identifiers": {(DOMAIN, self.config_entry.entry_id)}, - "name": self.config_entry.title, - "manufacturer": "Boiler Controller", - "model": "P1 to Dimmer Controller", - "sw_version": str(version), - } - def get_status(self): - """Get current controller status.""" - profile = self._calibration_profile or {} - return { - "last_dimmer_update": self._last_dimmer_update, - "last_power_value": self._last_power_value, - "power_sensor": self.power_sensor_id, - "shelly_url": self.shelly_url, - "shelly_status": self._shelly_status, - "update_method": "event_driven", - "calculator_min_interval": DEFAULT_CALCULATOR_MIN_INTERVAL, - "shelly_poll_interval": self.shelly_poll_interval, - "min_dimmer": self.min_dimmer_value, - "max_dimmer": self.max_dimmer_value, - "device_min_dimmer": self._device_min_dimmer_value, - "device_max_dimmer": self._device_max_dimmer_value, - "effective_min_dimmer": self._effective_min_dimmer_value, - "effective_max_dimmer": self._effective_max_dimmer_value, - "dimming_mode": self._dimming_mode, - "manual_brightness": self._manual_brightness, - "calibration_active": self._calibration_active, - "calibration_points": len(profile.get("points", [])) if profile else 0, - "calibration_created": profile.get("created") if profile else None, - } - - def get_shelly_status(self): - """Expose latest Shelly polling data.""" - return self._shelly_status + # Current boiler state from last polled status + current_boiler_watts: float = 0.0 + current_pct: int = 0 + if self._module_status: + current_boiler_watts = float( + self._module_status.get("power", 0) or 0 + ) + current_pct = int( + self._module_status.get("heatingPercentage", 0) or 0 + ) - def get_calibration_profile(self): - """Return the active calibration profile, if any.""" - return self._calibration_profile + new_pct = self._calculator.calculate( + grid_watts=power_value, + current_percentage=current_pct, + boiler_watts=current_boiler_watts, + ) - def get_shelly_status_signal(self): - """Return dispatcher signal name for Shelly status updates.""" - return self._dispatcher_signal + if new_pct != current_pct: + await self._set_heating_percentage(new_pct) - def get_dimming_mode_signal(self): - """Dispatcher signal for dimming mode changes.""" - return self._mode_signal + self._last_control_run = dt_util.utcnow() + self._last_update = self._last_control_run - def get_manual_brightness_signal(self): - """Dispatcher signal for manual brightness changes.""" - return self._manual_brightness_signal + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Error during controller update: %s", err) - def get_calibration_state_signal(self): - """Dispatcher signal fired when calibration state changes.""" - return self._calibration_signal + # ------------------------------------------------------------------ + # Module polling + # ------------------------------------------------------------------ - @property - def dimming_mode(self) -> str: - return self._dimming_mode + async def _async_poll_module(self) -> None: + """Poll module /api/status and /api/system at the configured interval.""" + while True: + try: + status = await self.boiler_client.async_get_status() + if status is not None: + self._module_status = status + + system = await self.boiler_client.async_get_system() + if system is not None: + self._module_system = system + + async_dispatcher_send( + self.hass, + self._status_signal, + { + "status": self._module_status, + "system": self._module_system, + }, + ) - @property - def manual_brightness(self) -> int: - return self._manual_brightness + await asyncio.sleep(self.poll_interval) - @property - def is_calibration_active(self) -> bool: - """Return True if a calibration sweep is currently running.""" - return self._calibration_active - - async def async_request_calibration_cancel(self) -> bool: - """Signal the active calibration run to stop after the current step.""" - if not self._calibration_active: - return False - if self._calibration_cancel_requested: - return True - self._calibration_cancel_requested = True - _LOGGER.info( - "Calibration cancellation requested for %s", - self.config_entry.title, - ) - return True - - async def async_set_dimming_mode(self, mode: str): - """Set dimming mode to auto or manual.""" - if self._calibration_active: - raise RuntimeError("Cannot change dimming mode during calibration") - if mode not in (DIMMER_MODE_AUTO, DIMMER_MODE_MANUAL): - raise ValueError(f"Unsupported dimming mode: {mode}") - if mode == self._dimming_mode: + except asyncio.CancelledError: + _LOGGER.debug("Module polling task cancelled") + break + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Unexpected module polling error: %s", err) + await asyncio.sleep(self.poll_interval) + + # ------------------------------------------------------------------ + # Heating control helpers + # ------------------------------------------------------------------ + + async def _set_heating_percentage(self, percentage: int) -> None: + """Send heating percentage to the boiler module API.""" + percentage = max(0, min(100, int(percentage))) + _LOGGER.info("Setting boiler heating to %d%%", percentage) + success = await self.boiler_client.async_set_heat(percentage) + if not success: + _LOGGER.warning("Failed to set boiler heating to %d%%", percentage) + + # ------------------------------------------------------------------ + # Manual control (used by select / number entities) + # ------------------------------------------------------------------ + + async def async_set_control_mode(self, mode: str) -> None: + """Switch between auto and manual control mode.""" + if mode not in CONTROL_MODES: + raise ValueError(f"Unsupported control mode: {mode}") + if mode == self._control_mode: return - self._dimming_mode = mode - self._persist_controller_options(dimming_mode=mode) + self._control_mode = mode + self._persist_options(control_mode=mode) async_dispatcher_send(self.hass, self._mode_signal, mode) - if mode == DIMMER_MODE_MANUAL: - await self._apply_manual_brightness() + if mode == CONTROL_MODE_MANUAL: + await self._set_heating_percentage(self._manual_percentage) else: await self._async_update() - async def async_set_manual_brightness(self, brightness: int): - """Store manual brightness and apply when manual mode is active.""" - if self._calibration_active: - raise RuntimeError("Cannot change manual brightness during calibration") - brightness = max(MIN_MANUAL_BRIGHTNESS, min(100, int(brightness))) - if brightness == self._manual_brightness: - return - self._manual_brightness = brightness - self._persist_controller_options(manual_brightness=self._manual_brightness) - async_dispatcher_send(self.hass, self._manual_brightness_signal, brightness) - - if self._dimming_mode == DIMMER_MODE_MANUAL: - await self._apply_manual_brightness() - - async def _apply_manual_brightness(self): - """Apply the stored manual brightness to the Shelly device.""" - _LOGGER.debug("Applying manual brightness override: %s%%", self._manual_brightness) - await self._set_dimmer_percentage(self._manual_brightness, source=DIMMER_MODE_MANUAL) - self._last_dimmer_update = dt_util.utcnow() - await self._async_refresh_shelly_status() - - async def async_run_calibration( - self, - *, - min_percentage: int, - max_percentage: int, - step_percentage: int, - settle_seconds: float, - ) -> dict | None: - """Drive the Shelly through a sweep to learn watts per brightness level.""" - - if min_percentage > max_percentage: - raise ValueError("min_percentage must be <= max_percentage") - if step_percentage <= 0: - raise ValueError("step_percentage must be greater than zero") - - settle_delay = max(1.0, float(settle_seconds)) - percentages = list(range(min_percentage, max_percentage + 1, step_percentage)) - if percentages[-1] != max_percentage: - percentages.append(max_percentage) - - if not percentages: - raise ValueError("No calibration points requested") - - if self._calibration_lock.locked(): - raise RuntimeError("A calibration run is already in progress") - - measurements: list[dict[str, float | int]] = [] - async with self._calibration_lock: - self._set_calibration_active(True) - self._calibration_cancel_requested = False - cancelled = False - self._enter_calibration_mode() - self._suspend_polling() - try: - for percentage in percentages: - if self._calibration_cancel_requested: - cancelled = True - break - await self._set_dimmer_percentage(percentage, source="calibration") - await asyncio.sleep(settle_delay) - if self._calibration_cancel_requested: - cancelled = True - break - watts = await self._async_measure_boiler_power() - measurements.append({"percentage": percentage, "watts": watts}) - _LOGGER.info( - "Calibration sample: %s%% -> %.2f W", - percentage, - watts, - ) - - if self._calibration_cancel_requested: - cancelled = True - break - - if cancelled: - _LOGGER.info( - "Calibration cancelled after recording %s samples", - len(measurements), - ) - return None - - profile = await self._calibration_store.async_save_points(measurements) - self._apply_calibration_profile(profile) - detail_lines = [ - f" - {point['percentage']}% -> {point['watts']:.2f} W" - for point in measurements - ] - _LOGGER.info( - "Calibration finished (%s points recorded):\n%s", - len(profile.get("points", [])), - "\n".join(detail_lines), - ) - return profile - finally: - self._calibration_cancel_requested = False - self._set_calibration_active(False) - await self._set_dimmer_percentage(0, source="calibration") - await self._async_refresh_shelly_status() - self._resume_polling() - restored_mode = self._exit_calibration_mode() - if restored_mode == DIMMER_MODE_AUTO: - await self._async_update() - else: - await self._apply_manual_brightness() - - def _extract_boiler_consumption(self) -> float: - """Return the latest Shelly-reported consumption in watts.""" - status = self._shelly_status or {} - if not status: - return 0.0 - value = status.get("apower", status.get("power")) - try: - return float(value) - except (TypeError, ValueError): - return 0.0 - - def _update_cached_brightness(self, status: dict | None) -> None: - """Cache the brightness reported by the Shelly status payload.""" - - if not status: - return - brightness = status.get("brightness", status.get("gain")) - if brightness is None: - return - try: - parsed = max(0, min(100, int(brightness))) - except (TypeError, ValueError): + async def async_set_manual_percentage(self, percentage: int) -> None: + """Store manual percentage and apply it when in manual mode.""" + percentage = max(MIN_MANUAL_PERCENTAGE, min(100, int(percentage))) + if percentage == self._manual_percentage: return - self._current_dimmer_percentage = parsed - - def _extract_boiler_brightness(self) -> int | None: - """Return the latest known dimmer percentage for the boiler.""" - if self._shelly_status: - self._update_cached_brightness(self._shelly_status) - return self._current_dimmer_percentage + self._manual_percentage = percentage + self._persist_options(manual_percentage=percentage) + async_dispatcher_send(self.hass, self._manual_pct_signal, percentage) - async def _async_measure_boiler_power(self) -> float: - """Force a Shelly status fetch and return the current power draw.""" - status = await self.shelly_client.async_get_status() - if status is None: - return 0.0 + if self._control_mode == CONTROL_MODE_MANUAL: + await self._set_heating_percentage(percentage) - self._shelly_status = status - self._update_cached_brightness(status) - async_dispatcher_send(self.hass, self._dispatcher_signal, status) + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ - value = status.get("apower", status.get("power")) - try: - return max(0.0, float(value)) - except (TypeError, ValueError): - return 0.0 - - async def _ensure_device_identity(self) -> None: - """Persist the Shelly device identifier on the config entry when missing.""" - if self.config_entry.data.get(CONF_SHELLY_ID): - return - - device_info = await self.shelly_client.async_get_device_info() - if not device_info: - _LOGGER.debug("Shelly device info unavailable for %s", self.shelly_url) - return - - device_id = ShellyClient.extract_device_id(device_info) - if not device_id: - _LOGGER.debug("Shelly device info missing identifier for %s", self.shelly_url) - return - - new_data = dict(self.config_entry.data) - new_data[CONF_SHELLY_ID] = device_id - self.hass.config_entries.async_update_entry(self.config_entry, data=new_data) - _LOGGER.info( - "Stored Shelly device id %s for entry %s", - device_id, - self.config_entry.entry_id, - ) - - def _persist_controller_options(self, **updates): - """Store controller runtime preferences in the config entry options.""" - if not updates: - return - - new_options = dict(self.config_entry.options) - changed = False - for key, value in updates.items(): - if value is None: - continue - if new_options.get(key) == value: - continue - new_options[key] = value - changed = True - - if changed: - self.hass.config_entries.async_update_entry( - self.config_entry, - options=new_options, + async def _validate_configuration(self) -> None: + state = self.hass.states.get(self.power_sensor_id) + if not state: + _LOGGER.info( + "Power sensor %s not found yet – will wait", self.power_sensor_id ) - - async def _async_sync_shelly_dimmer_constraints(self): - """Fetch Shelly light config to honor hardware brightness bounds.""" - config = await self.shelly_client.async_get_light_config() - if not config: - _LOGGER.debug("Could not load Shelly light config; using user dimmer bounds") - return - - device_min = self._extract_brightness_limit(config, limit_type="min") - device_max = self._extract_brightness_limit(config, limit_type="max") - - if device_min is not None: - self._device_min_dimmer_value = max(0, min(100, device_min)) - if device_max is not None: - self._device_max_dimmer_value = max(0, min(100, device_max)) - - self._recompute_effective_dimmer_bounds() - - def _recompute_effective_dimmer_bounds(self): - """Intersect user preferences with Shelly limits to get the enforceable range. - - The controller never sends a brightness outside this window, so this method - re-evaluates the currently valid minimum and maximum whenever either the - user-configured bounds or the device-reported constraints change. - """ - - new_min = self.min_dimmer_value # Start from the configured preference - if self._device_min_dimmer_value is not None: - new_min = max(new_min, self._device_min_dimmer_value) - - new_max = self.max_dimmer_value # Start from the configured preference - if self._device_max_dimmer_value is not None: - new_max = min(new_max, self._device_max_dimmer_value) - - if new_max < new_min: - new_max = new_min - - if (new_min != self._effective_min_dimmer_value) or (new_max != self._effective_max_dimmer_value): + else: _LOGGER.info( - "Effective dimmer bounds updated: min=%s%%, max=%s%% (user min=%s%%, user max=%s%%, device min=%s%%, device max=%s%%)", - new_min, - new_max, - self.min_dimmer_value, - self.max_dimmer_value, - self._device_min_dimmer_value, - self._device_max_dimmer_value, + "Power sensor %s found (current: %s)", self.power_sensor_id, state.state ) - self._effective_min_dimmer_value = new_min - self._effective_max_dimmer_value = new_max - - @staticmethod - def _extract_brightness_limit(config: dict, *, limit_type: str) -> int | None: - """Search Shelly config for brightness min/max values.""" - assert limit_type in {"min", "max"} - matches: list[int] = [] - - def _search(node): - if isinstance(node, dict): - for key, value in node.items(): - key_lower = key.lower() - if "bright" in key_lower and limit_type in key_lower and isinstance(value, (int, float)): - matches.append(int(value)) - else: - _search(value) - elif isinstance(node, list): - for item in node: - _search(item) - - _search(config) - - if not matches: + async def _get_p1_power_value(self) -> float | None: + """Return normalized W value from the configured P1 sensor entity.""" + state = self.hass.states.get(self.power_sensor_id) + if not state: + now = dt_util.utcnow() + if not hasattr(self, "_last_missing_log") or ( + now - self._last_missing_log + ).total_seconds() > 60: + _LOGGER.warning("Power sensor %s not found", self.power_sensor_id) + self._last_missing_log = now return None - return min(matches) if limit_type == "min" else max(matches) - - def _enter_calibration_mode(self) -> None: - """Temporarily suspend auto mode while calibration is running.""" - - if self._calibration_previous_mode is not None: - return - - self._calibration_previous_mode = self._dimming_mode - if self._dimming_mode == DIMMER_MODE_AUTO: - self._dimming_mode = DIMMER_MODE_MANUAL - async_dispatcher_send(self.hass, self._mode_signal, self._dimming_mode) - - def _exit_calibration_mode(self) -> str | None: - """Restore the dimming mode that was active before calibration started.""" - - if self._calibration_previous_mode is None: + if state.state in ("unknown", "unavailable", "none"): return None - previous_mode = self._calibration_previous_mode - self._calibration_previous_mode = None - - if self._dimming_mode != previous_mode: - self._dimming_mode = previous_mode - async_dispatcher_send(self.hass, self._mode_signal, self._dimming_mode) - - return previous_mode - - def _suspend_polling(self) -> None: - """Pause the Shelly polling loop while calibration owns the device.""" + try: + raw = float(state.state) + except (ValueError, TypeError): + _LOGGER.warning("Cannot parse power sensor value: %s", state.state) + return None - if self._polling_suspended: - return + unit = state.attributes.get( + "unit_of_measurement", + state.attributes.get("native_unit_of_measurement", ""), + ) + return self._normalize_power_unit(raw, str(unit) if unit else "") - self._polling_suspended = True - _LOGGER.debug("Shelly polling suspended for calibration") + @staticmethod + def _normalize_power_unit(value: float, unit: str) -> float: + """Convert kW to W when the unit indicates kilowatts.""" + if not unit: + return value + cleaned = unit.strip().lower() + if cleaned.startswith("kw") or "kilowatt" in cleaned: + return value * 1000 + return value + + def _persist_options(self, **kwargs: Any) -> None: + """Merge kwargs into the config entry options.""" + current = dict(self.config_entry.options) + current.update(kwargs) + self.hass.config_entries.async_update_entry( + self.config_entry, options=current + ) - def _resume_polling(self) -> None: - """Resume the Shelly polling loop after calibration completes.""" + # ------------------------------------------------------------------ + # Public accessors used by platform entities + # ------------------------------------------------------------------ - if not self._polling_suspended: - return + def get_status_signal(self) -> str: + return self._status_signal - self._polling_suspended = False - _LOGGER.debug("Shelly polling resumed after calibration") + def get_control_mode_signal(self) -> str: + return self._mode_signal - def _set_calibration_active(self, active: bool) -> None: - """Toggle calibration flag and broadcast state changes.""" - previous = self._calibration_active - self._calibration_active = active - if previous != active: - async_dispatcher_send(self.hass, self._calibration_signal, active) + def get_manual_pct_signal(self) -> str: + return self._manual_pct_signal - async def _async_load_calibration_profile(self) -> None: - """Load a stored calibration profile from disk and apply it.""" - try: - profile = await self._calibration_store.async_load_profile() - except Exception as err: # pylint: disable=broad-except - _LOGGER.warning("Failed to load calibration profile: %s", err) - profile = None + @property + def control_mode(self) -> str: + return self._control_mode - self._apply_calibration_profile(profile) - if profile: - _LOGGER.info( - "Loaded calibration profile with %s points", - len(profile.get("points", [])), - ) + @property + def manual_percentage(self) -> int: + return self._manual_percentage - def _apply_calibration_profile(self, profile: dict | None) -> None: - """Install the provided calibration profile or fall back to defaults.""" + def get_module_status(self) -> Dict[str, Any] | None: + """Return the latest /api/status payload.""" + return self._module_status - self._calibration_profile = profile - points = profile.get("points", []) if profile else [] - thresholds = points_to_thresholds(points) - self._calculator.set_calibration_profile(thresholds if thresholds else None) + def get_module_system(self) -> Dict[str, Any] | None: + """Return the latest /api/system payload (nested under 'system' key).""" + return self._module_system - @staticmethod - def _get_state_unit(state) -> str: - """Fetch unit from state attributes, falling back to native unit.""" - if not state: - return "" - unit = state.attributes.get("unit_of_measurement") - if not unit: - unit = state.attributes.get("native_unit_of_measurement") - if isinstance(unit, str): - return unit - return str(unit) if unit is not None else "" - - @staticmethod - def _normalize_power_unit(power_value: float, unit: str) -> float: - """Convert incoming power readings to watts.""" - if not unit: - return power_value + def get_status(self) -> Dict[str, Any]: + """Return a diagnostics summary of controller state.""" + calc_result = self._calculator.last_result + return { + "power_sensor": self.power_sensor_id, + "boiler_host": self.boiler_client.host, + "control_mode": self._control_mode, + "manual_percentage": self._manual_percentage, + "last_power_value": self._last_power_value, + "last_update": self._last_update, + "poll_interval": self.poll_interval, + "max_boiler_watts": self.max_boiler_watts, + "last_target_pct": calc_result.target_percentage if calc_result else None, + "last_available_watts": calc_result.available_watts if calc_result else None, + } - cleaned = unit.strip().lower() - # Handle variations like kW, kilo watt, kilowatt, etc. - if cleaned.startswith("kw") or "kilowatt" in cleaned: - return power_value * 1000 - # No conversion needed for W-based units - return power_value \ No newline at end of file + @property + def device_info(self) -> Dict[str, Any]: + from .const import VERSION as DEFAULT_VERSION + version = self.integration_version or DEFAULT_VERSION + return { + "identifiers": {(DOMAIN, self.config_entry.entry_id)}, + "name": self.config_entry.title, + "manufacturer": "Boiler Controller", + "model": "Boiler Controller Module", + "sw_version": str(version), + } diff --git a/custom_components/boiler_controller/manifest.json b/custom_components/boiler_controller/manifest.json index 3739182..d2ecc3e 100644 --- a/custom_components/boiler_controller/manifest.json +++ b/custom_components/boiler_controller/manifest.json @@ -8,11 +8,8 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/BoilerController/boiler-controller-ha/issues", "requirements": [], - "version": "1.0.0", + "version": "2.0.0", "zeroconf": [ - { "type": "_http._tcp.local.", "name": "shelly0110dimg3-*" }, - { "type": "_http._tcp.local.", "name": "shellyplusdimg3-*" }, - { "type": "_shelly._tcp.local.", "name": "shelly0110dimg3-*" }, - { "type": "_shelly._tcp.local.", "name": "shellyplusdimg3-*" } + { "type": "_http._tcp.local.", "name": "boiler-controller-*" } ] } diff --git a/custom_components/boiler_controller/number.py b/custom_components/boiler_controller/number.py index 6fd9497..0db0adc 100644 --- a/custom_components/boiler_controller/number.py +++ b/custom_components/boiler_controller/number.py @@ -1,4 +1,4 @@ -"""Number entities for controlling manual brightness.""" +"""Number entity for manual heating percentage override.""" from __future__ import annotations from typing import Callable, List @@ -6,11 +6,10 @@ from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, MIN_MANUAL_BRIGHTNESS +from .const import DOMAIN, MIN_MANUAL_PERCENTAGE, CONTROL_MODE_MANUAL async def async_setup_entry( @@ -18,43 +17,47 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up number entities for this config entry.""" controller = hass.data[DOMAIN][config_entry.entry_id]["controller"] - async_add_entities([BoilerControllerManualBrightnessNumber(hass, config_entry, controller)]) + async_add_entities( + [BoilerManualPercentageNumber(hass, config_entry, controller)] + ) -class BoilerControllerManualBrightnessNumber(NumberEntity): - """Number entity exposing manual brightness override.""" +class BoilerManualPercentageNumber(NumberEntity): + """Number entity for the manual heating percentage (0-100 %).""" _attr_should_poll = False - _attr_native_min_value = MIN_MANUAL_BRIGHTNESS + _attr_native_min_value = MIN_MANUAL_PERCENTAGE _attr_native_max_value = 100 _attr_native_step = 1 - _attr_mode = NumberMode.BOX - _attr_icon = "mdi:brightness-percent" + _attr_mode = NumberMode.SLIDER + _attr_icon = "mdi:thermometer" + _attr_native_unit_of_measurement = "%" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, controller + ) -> None: self.hass = hass self.config_entry = config_entry self.controller = controller - self._attr_name = f"{config_entry.title} Manual Brightness" - self._attr_unique_id = f"{config_entry.entry_id}_manual_brightness" - self._attr_native_value = controller.manual_brightness + self._attr_name = f"{config_entry.title} Manual Heating" + self._attr_unique_id = f"{config_entry.entry_id}_manual_heating" + self._attr_native_value = controller.manual_percentage self._remove_callbacks: List[Callable[[], None]] = [] async def async_added_to_hass(self) -> None: self._remove_callbacks.append( async_dispatcher_connect( self.hass, - self.controller.get_manual_brightness_signal(), - self._handle_manual_brightness_update, + self.controller.get_manual_pct_signal(), + self._handle_manual_pct_update, ) ) self._remove_callbacks.append( async_dispatcher_connect( self.hass, - self.controller.get_calibration_state_signal(), - self._handle_calibration_state, + self.controller.get_control_mode_signal(), + self._handle_mode_update, ) ) self.async_write_ha_state() @@ -65,31 +68,29 @@ async def async_will_remove_from_hass(self) -> None: self._remove_callbacks.clear() @callback - def _handle_manual_brightness_update(self, value: int) -> None: + def _handle_manual_pct_update(self, value: int) -> None: self._attr_native_value = value self.async_write_ha_state() - async def async_set_native_value(self, value: float) -> None: - if self.controller.is_calibration_active: - raise HomeAssistantError("Cannot change manual brightness during calibration") - await self.controller.async_set_manual_brightness(int(value)) - @callback - def _handle_calibration_state(self, *_: object) -> None: + def _handle_mode_update(self, _mode: str) -> None: self.async_write_ha_state() @property def available(self) -> bool: - return super().available and not self.controller.is_calibration_active + return self.controller.control_mode == CONTROL_MODE_MANUAL + + async def async_set_native_value(self, value: float) -> None: + await self.controller.async_set_manual_percentage(int(value)) @property def device_info(self): from .const import VERSION as DEFAULT_VERSION - version = self.controller.integration_version if self.controller.integration_version else DEFAULT_VERSION + version = self.controller.integration_version or DEFAULT_VERSION return { "identifiers": {(DOMAIN, self.config_entry.entry_id)}, "name": self.config_entry.title, "manufacturer": "Boiler Controller", - "model": "P1 to Shelly Controller", + "model": "Boiler Controller Module", "sw_version": str(version), } diff --git a/custom_components/boiler_controller/select.py b/custom_components/boiler_controller/select.py index 2a40afc..64424e2 100644 --- a/custom_components/boiler_controller/select.py +++ b/custom_components/boiler_controller/select.py @@ -1,16 +1,15 @@ -"""Select entities for the Boiler Controller integration.""" +"""Select entity for toggling auto/manual control mode.""" from __future__ import annotations -from typing import List, Callable +from typing import Callable, List from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, DIMMER_MODES +from .const import DOMAIN, CONTROL_MODES async def async_setup_entry( @@ -18,42 +17,36 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up select entities for this config entry.""" controller = hass.data[DOMAIN][config_entry.entry_id]["controller"] - async_add_entities([BoilerControllerModeSelect(hass, config_entry, controller)]) + async_add_entities([BoilerControlModeSelect(hass, config_entry, controller)]) -class BoilerControllerModeSelect(SelectEntity): - """Select entity toggling automatic/manual dimming.""" +class BoilerControlModeSelect(SelectEntity): + """Select entity to switch between automatic and manual heating control.""" _attr_should_poll = False - _attr_options = DIMMER_MODES - _attr_icon = "mdi:lightning-bolt-outline" + _attr_options = CONTROL_MODES + _attr_icon = "mdi:auto-mode" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, controller + ) -> None: self.hass = hass self.config_entry = config_entry self.controller = controller - self._attr_name = f"{config_entry.title} Dimmer Mode" - self._attr_unique_id = f"{config_entry.entry_id}_dimmer_mode" - self._attr_current_option = controller.dimming_mode + self._attr_name = f"{config_entry.title} Control Mode" + self._attr_unique_id = f"{config_entry.entry_id}_control_mode" + self._attr_current_option = controller.control_mode self._remove_callbacks: List[Callable[[], None]] = [] async def async_added_to_hass(self) -> None: self._remove_callbacks.append( async_dispatcher_connect( self.hass, - self.controller.get_dimming_mode_signal(), + self.controller.get_control_mode_signal(), self._handle_mode_update, ) ) - self._remove_callbacks.append( - async_dispatcher_connect( - self.hass, - self.controller.get_calibration_state_signal(), - self._handle_calibration_state, - ) - ) self.async_write_ha_state() async def async_will_remove_from_hass(self) -> None: @@ -66,27 +59,17 @@ def _handle_mode_update(self, mode: str) -> None: self._attr_current_option = mode self.async_write_ha_state() - @callback - def _handle_calibration_state(self, *_: object) -> None: - self.async_write_ha_state() - async def async_select_option(self, option: str) -> None: - if self.controller.is_calibration_active: - raise HomeAssistantError("Cannot change mode while calibration is running") - await self.controller.async_set_dimming_mode(option) - - @property - def available(self) -> bool: - return super().available and not self.controller.is_calibration_active + await self.controller.async_set_control_mode(option) @property def device_info(self): from .const import VERSION as DEFAULT_VERSION - version = self.controller.integration_version if self.controller.integration_version else DEFAULT_VERSION + version = self.controller.integration_version or DEFAULT_VERSION return { "identifiers": {(DOMAIN, self.config_entry.entry_id)}, "name": self.config_entry.title, "manufacturer": "Boiler Controller", - "model": "P1 to Shelly Controller", + "model": "Boiler Controller Module", "sw_version": str(version), } diff --git a/custom_components/boiler_controller/sensor.py b/custom_components/boiler_controller/sensor.py index 64efe54..438e764 100644 --- a/custom_components/boiler_controller/sensor.py +++ b/custom_components/boiler_controller/sensor.py @@ -1,3 +1,6 @@ +"""Sensor entities for the Boiler Controller integration.""" +from __future__ import annotations + import logging from typing import Any, Callable, Dict, List, Optional @@ -9,54 +12,45 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.entity import EntityCategory -from homeassistant.util import dt as dt_util +from homeassistant.helpers.entity_platform import AddEntitiesCallback try: from homeassistant.const import ( PERCENTAGE, - UnitOfElectricCurrent, - UnitOfElectricPotentialDifference, - UnitOfEnergy, UnitOfPower, UnitOfTemperature, + UnitOfEnergy, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) - - UNIT_CURRENT = UnitOfElectricCurrent.AMPERE - UNIT_VOLTAGE = UnitOfElectricPotentialDifference.VOLT UNIT_POWER = UnitOfPower.WATT UNIT_TEMP = UnitOfTemperature.CELSIUS - UNIT_ENERGY = UnitOfEnergy.KILO_WATT_HOUR + UNIT_ENERGY = UnitOfEnergy.WATT_HOUR + UNIT_RSSI = SIGNAL_STRENGTH_DECIBELS_MILLIWATT except ImportError: - from homeassistant.const import PERCENTAGE - - UNIT_CURRENT = "A" - UNIT_VOLTAGE = "V" + PERCENTAGE = "%" UNIT_POWER = "W" UNIT_TEMP = "°C" - UNIT_ENERGY = "kWh" + UNIT_ENERGY = "Wh" + UNIT_RSSI = "dBm" -from .const import DOMAIN, VERSION +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) def _integration_version(controller, config_entry: ConfigEntry) -> str: from .const import VERSION as DEFAULT_VERSION - version = controller.integration_version if controller.integration_version else DEFAULT_VERSION - return str(version) + return str(controller.integration_version or DEFAULT_VERSION) def _device_info(config_entry: ConfigEntry, controller) -> Dict[str, Any]: - """Return standard device info for entities owned by this entry.""" version = _integration_version(controller, config_entry) return { "identifiers": {(DOMAIN, config_entry.entry_id)}, "name": config_entry.title, "manufacturer": "Boiler Controller", - "model": "P1 to Shelly Controller", + "model": "Boiler Controller Module", "sw_version": version, } @@ -66,532 +60,408 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Boiler Controller sensors.""" - controller_data = hass.data[DOMAIN][config_entry.entry_id] - controller = controller_data["controller"] - - sensors: List[SensorEntity] = [ - BoilerControllerStatusSensor(hass, config_entry, controller), - PowerSensorStatusSensor(hass, config_entry, controller), - LastDimmerUpdateSensor(hass, config_entry, controller), - ShellyBrightnessSensor(hass, config_entry, controller), - ShellyVoltageSensor(hass, config_entry, controller), - ShellyCurrentSensor(hass, config_entry, controller), - ShellyPowerSensor(hass, config_entry, controller), - ShellyTemperatureSensor(hass, config_entry, controller), - ShellyEnergySensor(hass, config_entry, controller), + """Set up Boiler Controller sensor entities.""" + controller = hass.data[DOMAIN][config_entry.entry_id]["controller"] + + entities: List[SensorEntity] = [ + # Main status sensor + BoilerStatusSensor(hass, config_entry, controller), + # Live data from /api/status + BoilerPowerSensor(hass, config_entry, controller), + BoilerHeatingPercentageSensor(hass, config_entry, controller), + BoilerTemperatureSensor(hass, config_entry, controller), + BoilerTotalEnergySensor(hass, config_entry, controller), + BoilerRssiSensor(hass, config_entry, controller), + # System info from /api/system + BoilerFirmwareVersionSensor(hass, config_entry, controller), + BoilerWifiStrengthSensor(hass, config_entry, controller), + # Diagnostics + P1PowerSensor(hass, config_entry, controller), ] - async_add_entities(sensors) + async_add_entities(entities) + +# --------------------------------------------------------------------------- +# Base class +# --------------------------------------------------------------------------- -class BoilerControllerStatusSensor(SensorEntity): - """High-level status sensor for the controller.""" + +class _BoilerSensorBase(SensorEntity): + """Base class for sensors that refresh on the status dispatcher signal.""" _attr_should_poll = False - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + controller, + *, + name_suffix: str, + unique_suffix: str, + icon: Optional[str] = None, + ) -> None: self.hass = hass self.config_entry = config_entry self.controller = controller - self._attr_name = f"{config_entry.title} Status" - self._attr_unique_id = f"{config_entry.entry_id}_status" - self._attr_icon = "mdi:thermostat" - self._remove_callbacks: List[Callable] = [] + self._attr_name = f"{config_entry.title} {name_suffix}" + self._attr_unique_id = f"{config_entry.entry_id}_{unique_suffix}" + self._attr_icon = icon + self._remove_dispatcher: Optional[Callable] = None async def async_added_to_hass(self) -> None: - self._remove_callbacks.append( - async_track_state_change_event( - self.hass, - [self.controller.power_sensor_id], - self._handle_power_update, - ) - ) - self._remove_callbacks.append( - async_dispatcher_connect( - self.hass, - self.controller.get_shelly_status_signal(), - self._handle_shelly_update, - ) + self._remove_dispatcher = async_dispatcher_connect( + self.hass, + self.controller.get_status_signal(), + self._handle_status_update, ) self.async_write_ha_state() async def async_will_remove_from_hass(self) -> None: - for remove in self._remove_callbacks: - remove() - self._remove_callbacks.clear() + if self._remove_dispatcher: + self._remove_dispatcher() + self._remove_dispatcher = None @callback - def _handle_power_update(self, event) -> None: + def _handle_status_update(self, payload: dict) -> None: self.async_write_ha_state() - @callback - def _handle_shelly_update(self, status) -> None: - self.async_write_ha_state() + @property + def device_info(self) -> Dict[str, Any]: + return _device_info(self.config_entry, self.controller) + + +# --------------------------------------------------------------------------- +# Main status sensor +# --------------------------------------------------------------------------- + + +class BoilerStatusSensor(_BoilerSensorBase): + """High-level status sensor for the Boiler Controller.""" + + def __init__(self, hass, config_entry, controller) -> None: + super().__init__( + hass, + config_entry, + controller, + name_suffix="Status", + unique_suffix="status", + icon="mdi:water-boiler", + ) @property def state(self) -> str: - if self.controller.is_calibration_active: - return "Calibration" - status = self.controller.get_shelly_status() or {} - if status.get("errors"): - return "Error" - if status.get("output"): - return "Running" - return "Idle" + status = self.controller.get_module_status() or {} + pct = status.get("heatingPercentage") + if pct is None: + return "unavailable" + if pct == 0: + return "idle" + return "heating" @property def extra_state_attributes(self) -> Dict[str, Any]: - # Surface diagnostics/details without overloading the main sensor state + ctrl = self.controller.get_status() + status = self.controller.get_module_status() or {} + system_payload = self.controller.get_module_system() or {} + system = system_payload.get("system", {}) or {} + attrs: Dict[str, Any] = { - "power_sensor": self.controller.power_sensor_id, - "shelly_url": self.controller.shelly_url, - "shelly_poll_interval": f"{self.controller.shelly_poll_interval}s", - "integration_version": _integration_version(self.controller, self.config_entry), + "control_mode": ctrl.get("control_mode"), + "manual_percentage": ctrl.get("manual_percentage"), + "power_sensor": ctrl.get("power_sensor"), + "boiler_host": ctrl.get("boiler_host"), + "poll_interval": f"{ctrl.get('poll_interval')}s", + "max_boiler_watts": ctrl.get("max_boiler_watts"), + "last_power_value": ctrl.get("last_power_value"), + "last_target_percentage": ctrl.get("last_target_pct"), + "last_available_watts": ctrl.get("last_available_watts"), + "last_update": ctrl.get("last_update"), + "integration_version": _integration_version( + self.controller, self.config_entry + ), } - controller_status = self.controller.get_status() - attrs.update( - { - "min_dimmer": controller_status.get("min_dimmer"), - "max_dimmer": controller_status.get("max_dimmer"), - "device_min_dimmer": controller_status.get("device_min_dimmer"), - "device_max_dimmer": controller_status.get("device_max_dimmer"), - "effective_min_dimmer": controller_status.get("effective_min_dimmer"), - "effective_max_dimmer": controller_status.get("effective_max_dimmer"), - "last_dimmer_update": controller_status.get("last_dimmer_update"), - "manual_mode": controller_status.get("dimming_mode") == "manual", - "calibration_active": controller_status.get("calibration_active", False), - "calibration_points": controller_status.get("calibration_points", 0), - "calibration_created": controller_status.get("calibration_created"), - } - ) - - power_state = self.hass.states.get(self.controller.power_sensor_id) - if power_state: + if status: attrs.update( { - "power_sensor_status": "available", - "power_sensor_value": power_state.state, - "power_sensor_unit": power_state.attributes.get("unit_of_measurement", "W"), + "power_w": status.get("power"), + "heating_percentage": status.get("heatingPercentage"), + "temperature_c": status.get("temperature"), + "total_wh": status.get("total"), + "rssi_dbm": status.get("rssi"), } ) - else: - attrs["power_sensor_status"] = "missing" - status = self.controller.get_shelly_status() - if status: + if system: attrs.update( { - "shelly_source": status.get("source"), - "shelly_output": status.get("output"), - "shelly_brightness": status.get("brightness"), - "shelly_voltage": status.get("voltage"), - "shelly_current": status.get("current"), - "shelly_power": status.get("apower", status.get("power")), + "firmware_version": system.get("firmwareVersion"), + "cpu_frequency": system.get("cpuFrequency"), + "module_ip": system.get("ip"), + "wifi_strength_dbm": system.get("wifiStrength"), } ) - temperature = status.get("temperature") - if isinstance(temperature, dict): - attrs["shelly_temperature_c"] = temperature.get("tC") - elif isinstance(temperature, (int, float)): - attrs["shelly_temperature_c"] = temperature - energy = status.get("aenergy") - if isinstance(energy, dict) and isinstance(energy.get("total"), (int, float)): - attrs["shelly_energy_wh"] = energy["total"] - else: - attrs["shelly_status"] = "unavailable" - - if self.controller._last_dimmer_update: - attrs["last_dimmer_update"] = self.controller._last_dimmer_update.isoformat() - if self.controller._last_power_value is not None: - attrs["last_power_value"] = self.controller._last_power_value return attrs - @property - def device_info(self) -> Dict[str, Any]: - return _device_info(self.config_entry, self.controller) +# --------------------------------------------------------------------------- +# /api/status sensors +# --------------------------------------------------------------------------- -class PowerSensorStatusSensor(SensorEntity): - """Expose the configured power sensor state for debugging.""" - _attr_should_poll = False - _attr_device_class = SensorDeviceClass.POWER - _attr_native_unit_of_measurement = "W" - _attr_entity_category = EntityCategory.DIAGNOSTIC +class BoilerPowerSensor(_BoilerSensorBase): + """Actual power consumption of the boiler (W).""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) -> None: - self.hass = hass - self.config_entry = config_entry - self.controller = controller - self._attr_name = f"{config_entry.title} Power Sensor" - self._attr_unique_id = f"{config_entry.entry_id}_power_sensor" - self._remove_callbacks: List[Callable] = [] + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UNIT_POWER - async def async_added_to_hass(self) -> None: - self._remove_callbacks.append( - async_track_state_change_event( - self.hass, - [self.controller.power_sensor_id], - self._handle_power_update, - ) + def __init__(self, hass, config_entry, controller) -> None: + super().__init__( + hass, + config_entry, + controller, + name_suffix="Power", + unique_suffix="power", + icon="mdi:lightning-bolt", ) - self.async_write_ha_state() - - async def async_will_remove_from_hass(self) -> None: - for remove in self._remove_callbacks: - remove() - self._remove_callbacks.clear() - - @callback - def _handle_power_update(self, event) -> None: - self.async_write_ha_state() @property def native_value(self) -> Optional[float]: - power_state = self.hass.states.get(self.controller.power_sensor_id) - if power_state: - try: - value = float(power_state.state) - unit = self._extract_unit(power_state) - return self._normalize_power_unit(value, unit) - except (ValueError, TypeError): - return None - return None - - @property - def extra_state_attributes(self) -> Dict[str, Any]: - # Report availability metadata that helps diagnose the underlying power entity - state = self.hass.states.get(self.controller.power_sensor_id) - if not state: - return {"status": "missing"} - return { - "status": "available", - "last_changed": state.last_changed.isoformat(), - "last_updated": state.last_updated.isoformat(), - "unit": self._extract_unit(state) or "", - } - - @staticmethod - def _extract_unit(state) -> str: - unit = state.attributes.get("unit_of_measurement") - if not unit: - unit = state.attributes.get("native_unit_of_measurement") - if isinstance(unit, str): - return unit - return str(unit) if unit is not None else "" - - @staticmethod - def _normalize_power_unit(power_value: float, unit: str) -> float: - if not unit: - return power_value - - cleaned = unit.strip().lower() - if cleaned.startswith("kw") or "kilowatt" in cleaned: - return power_value * 1000 - return power_value - - @property - def device_info(self) -> Dict[str, Any]: - return _device_info(self.config_entry, self.controller) - - -class LastDimmerUpdateSensor(SensorEntity): - """Sensor showing when the controller last adjusted the Shelly dimmer.""" - - _attr_should_poll = False - _attr_device_class = SensorDeviceClass.TIMESTAMP - _attr_entity_category = EntityCategory.DIAGNOSTIC - - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) -> None: - self.hass = hass - self.config_entry = config_entry - self.controller = controller - self._attr_name = f"{config_entry.title} Last Dimmer Update" - self._attr_unique_id = f"{config_entry.entry_id}_last_dimmer_update" - self._attr_icon = "mdi:clock-outline" - self._remove_callbacks: List[Callable] = [] - - async def async_added_to_hass(self) -> None: - self._remove_callbacks.append( - async_track_state_change_event( - self.hass, - [self.controller.power_sensor_id], - self._handle_update, - ) - ) - self._remove_callbacks.append( - async_dispatcher_connect( - self.hass, - self.controller.get_shelly_status_signal(), - self._handle_dispatcher_update, - ) - ) - self.async_write_ha_state() - - async def async_will_remove_from_hass(self) -> None: - for remove in self._remove_callbacks: - remove() - self._remove_callbacks.clear() - - @callback - def _handle_update(self, event) -> None: - self.async_write_ha_state() - - @callback - def _handle_dispatcher_update(self, status) -> None: - self.async_write_ha_state() - - @property - def native_value(self): - value = self.controller._last_dimmer_update - if isinstance(value, str): - parsed = dt_util.parse_datetime(value) - if parsed is not None: - return parsed - return value - - @property - def extra_state_attributes(self) -> Dict[str, Any]: - # Keep transport/update metadata in attributes so the sensor value remains a timestamp - attrs = { - "update_method": "event_driven", - "integration_version": _integration_version(self.controller, self.config_entry), - } - if self.controller._last_power_value is not None: - attrs["last_power_value"] = self.controller._last_power_value - return attrs - - @property - def device_info(self) -> Dict[str, Any]: - return _device_info(self.config_entry, self.controller) - + status = self.controller.get_module_status() or {} + val = status.get("power") + return float(val) if val is not None else None -class ShellySensorBase(SensorEntity): - """Base class for Shelly telemetry sensors fed by the controller polling loop.""" - _attr_should_poll = False - _attr_entity_category = EntityCategory.DIAGNOSTIC +class BoilerHeatingPercentageSensor(_BoilerSensorBase): + """Current heating percentage (0-100 %).""" - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - controller, - *, - name_suffix: str, - unique_suffix: str, - icon: Optional[str] = None, - ) -> None: - self.hass = hass - self.config_entry = config_entry - self.controller = controller - self._attr_name = f"{config_entry.title} {name_suffix}" - self._attr_unique_id = f"{config_entry.entry_id}_{unique_suffix}" - self._attr_icon = icon - self._attr_available = False - self._attr_native_value: Optional[float] = None - self._remove_dispatcher: Optional[Callable] = None + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT - async def async_added_to_hass(self) -> None: - self._remove_dispatcher = async_dispatcher_connect( - self.hass, - self.controller.get_shelly_status_signal(), - self._handle_shelly_update, + def __init__(self, hass, config_entry, controller) -> None: + super().__init__( + hass, + config_entry, + controller, + name_suffix="Heating Percentage", + unique_suffix="heating_percentage", + icon="mdi:percent", ) - self._handle_shelly_update(self.controller.get_shelly_status()) - - async def async_will_remove_from_hass(self) -> None: - if self._remove_dispatcher: - self._remove_dispatcher() - self._remove_dispatcher = None - - @callback - def _handle_shelly_update(self, status: Optional[Dict[str, Any]]) -> None: - if not status: - if self._attr_available: - self._attr_available = False - self._attr_native_value = None - self._attr_extra_state_attributes = {} - self.async_write_ha_state() - return - - self._attr_available = True - self._attr_native_value = self._extract_value(status) - self._attr_extra_state_attributes = self._build_extra_state_attributes(status) - self.async_write_ha_state() - - def _extract_value(self, status: Dict[str, Any]): - raise NotImplementedError - - def _build_extra_state_attributes(self, status: Dict[str, Any]) -> Dict[str, Any]: - return { - "shelly_source": status.get("source"), - "shelly_output": status.get("output"), - "errors": status.get("errors"), - } @property - def device_info(self) -> Dict[str, Any]: - return _device_info(self.config_entry, self.controller) + def native_value(self) -> Optional[int]: + status = self.controller.get_module_status() or {} + val = status.get("heatingPercentage") + return int(val) if val is not None else None -class ShellyBrightnessSensor(ShellySensorBase): - """Expose Shelly brightness (percentage).""" +class BoilerTemperatureSensor(_BoilerSensorBase): + """Boiler water temperature (°C).""" - _attr_native_unit_of_measurement = PERCENTAGE + _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UNIT_TEMP def __init__(self, hass, config_entry, controller) -> None: super().__init__( hass, config_entry, controller, - name_suffix="Shelly Brightness", - unique_suffix="shelly_brightness", - icon="mdi:brightness-percent", + name_suffix="Temperature", + unique_suffix="temperature", ) - def _extract_value(self, status: Dict[str, Any]) -> Optional[int]: - brightness = status.get("brightness") - if isinstance(brightness, (int, float)): - return int(brightness) - if status.get("output") is False: - return 0 - return None + @property + def native_value(self) -> Optional[float]: + status = self.controller.get_module_status() or {} + val = status.get("temperature") + return float(val) if val is not None else None -class ShellyVoltageSensor(ShellySensorBase): - """Expose Shelly reported voltage.""" +class BoilerTotalEnergySensor(_BoilerSensorBase): + """Total energy delivered to the boiler (Wh).""" - _attr_device_class = SensorDeviceClass.VOLTAGE - _attr_native_unit_of_measurement = UNIT_VOLTAGE - _attr_state_class = SensorStateClass.MEASUREMENT + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_native_unit_of_measurement = UNIT_ENERGY def __init__(self, hass, config_entry, controller) -> None: super().__init__( hass, config_entry, controller, - name_suffix="Shelly Voltage", - unique_suffix="shelly_voltage", - icon="mdi:sine-wave", + name_suffix="Total Energy", + unique_suffix="total_energy", + icon="mdi:counter", ) - def _extract_value(self, status: Dict[str, Any]) -> Optional[float]: - voltage = status.get("voltage") - if isinstance(voltage, (int, float)): - return round(voltage, 2) - return None + @property + def native_value(self) -> Optional[float]: + status = self.controller.get_module_status() or {} + val = status.get("total") + return float(val) if val is not None else None -class ShellyCurrentSensor(ShellySensorBase): - """Expose Shelly reported current.""" +class BoilerRssiSensor(_BoilerSensorBase): + """RSSI of the boiler module's Wi-Fi connection (dBm).""" - _attr_device_class = SensorDeviceClass.CURRENT - _attr_native_unit_of_measurement = UNIT_CURRENT + _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UNIT_RSSI + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__(self, hass, config_entry, controller) -> None: super().__init__( hass, config_entry, controller, - name_suffix="Shelly Current", - unique_suffix="shelly_current", - icon="mdi:current-ac", + name_suffix="RSSI", + unique_suffix="rssi", ) - def _extract_value(self, status: Dict[str, Any]) -> Optional[float]: - current = status.get("current") - if isinstance(current, (int, float)): - return round(current, 3) - return None + @property + def native_value(self) -> Optional[int]: + status = self.controller.get_module_status() or {} + val = status.get("rssi") + return int(val) if val is not None else None + +# --------------------------------------------------------------------------- +# /api/system sensors +# --------------------------------------------------------------------------- -class ShellyPowerSensor(ShellySensorBase): - """Expose Shelly reported active power.""" - _attr_device_class = SensorDeviceClass.POWER - _attr_native_unit_of_measurement = UNIT_POWER - _attr_state_class = SensorStateClass.MEASUREMENT +class BoilerFirmwareVersionSensor(_BoilerSensorBase): + """Firmware version reported by the boiler module.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__(self, hass, config_entry, controller) -> None: super().__init__( hass, config_entry, controller, - name_suffix="Shelly Power", - unique_suffix="shelly_power", - icon="mdi:flash", + name_suffix="Firmware Version", + unique_suffix="firmware_version", + icon="mdi:chip", ) - def _extract_value(self, status: Dict[str, Any]) -> Optional[float]: - power = status.get("apower") - if power is None: - power = status.get("power") - if isinstance(power, (int, float)): - return round(power, 1) - return None + @property + def native_value(self) -> Optional[str]: + system_payload = self.controller.get_module_system() or {} + system = system_payload.get("system") or {} + val = system.get("firmwareVersion") + return str(val) if val is not None else None + @property + def extra_state_attributes(self) -> Dict[str, Any]: + system_payload = self.controller.get_module_system() or {} + system = system_payload.get("system") or {} + return { + "cpu_frequency": system.get("cpuFrequency"), + "module_ip": system.get("ip"), + "current_datetime": system.get("currentDateTime"), + "up_since": system.get("upSince"), + } -class ShellyTemperatureSensor(ShellySensorBase): - """Expose Shelly internal temperature.""" - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_native_unit_of_measurement = UNIT_TEMP +class BoilerWifiStrengthSensor(_BoilerSensorBase): + """Wi-Fi signal strength from /api/system (dBm).""" + + _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UNIT_RSSI + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__(self, hass, config_entry, controller) -> None: super().__init__( hass, config_entry, controller, - name_suffix="Shelly Temperature", - unique_suffix="shelly_temperature", - icon="mdi:thermometer", + name_suffix="WiFi Strength", + unique_suffix="wifi_strength", ) - def _extract_value(self, status: Dict[str, Any]) -> Optional[float]: - temperature = status.get("temperature") - if isinstance(temperature, dict): - temperature = temperature.get("tC") - if isinstance(temperature, (int, float)): - return round(temperature, 1) - return None + @property + def native_value(self) -> Optional[int]: + system_payload = self.controller.get_module_system() or {} + system = system_payload.get("system") or {} + val = system.get("wifiStrength") + return int(val) if val is not None else None -class ShellyEnergySensor(ShellySensorBase): - """Expose Shelly cumulative energy in kWh.""" +# --------------------------------------------------------------------------- +# P1 power sensor mirror (diagnostic) +# --------------------------------------------------------------------------- - _attr_device_class = SensorDeviceClass.ENERGY - _attr_native_unit_of_measurement = UNIT_ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - def __init__(self, hass, config_entry, controller) -> None: - super().__init__( - hass, - config_entry, - controller, - name_suffix="Shelly Energy", - unique_suffix="shelly_energy", - icon="mdi:lightning-bolt", +class P1PowerSensor(SensorEntity): + """Mirror the configured P1 power sensor for diagnostics.""" + + _attr_should_poll = False + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UNIT_POWER + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) -> None: + self.hass = hass + self.config_entry = config_entry + self.controller = controller + self._attr_name = f"{config_entry.title} P1 Power" + self._attr_unique_id = f"{config_entry.entry_id}_p1_power" + self._attr_icon = "mdi:transmission-tower" + self._remove_dispatcher: Optional[Callable] = None + + async def async_added_to_hass(self) -> None: + self._remove_dispatcher = async_dispatcher_connect( + self.hass, + self.controller.get_status_signal(), + self._handle_update, ) + self.async_write_ha_state() + + async def async_will_remove_from_hass(self) -> None: + if self._remove_dispatcher: + self._remove_dispatcher() + self._remove_dispatcher = None + + @callback + def _handle_update(self, _: Any) -> None: + self.async_write_ha_state() - def _extract_value(self, status: Dict[str, Any]) -> Optional[float]: - energy = status.get("aenergy") - total = None - if isinstance(energy, dict): - total = energy.get("total") - if isinstance(total, (int, float)): - return round(total / 1000, 3) - return None - - def _build_extra_state_attributes(self, status: Dict[str, Any]) -> Dict[str, Any]: - attrs = super()._build_extra_state_attributes(status) - energy = status.get("aenergy") - if isinstance(energy, dict) and isinstance(energy.get("total"), (int, float)): - attrs["shelly_energy_wh"] = energy["total"] - return attrs \ No newline at end of file + @property + def native_value(self) -> Optional[float]: + state = self.hass.states.get(self.controller.power_sensor_id) + if not state or state.state in ("unknown", "unavailable", "none"): + return None + try: + raw = float(state.state) + unit = str( + state.attributes.get("unit_of_measurement") or "" + ) + if unit.strip().lower().startswith("kw"): + return raw * 1000 + return raw + except (ValueError, TypeError): + return None + + @property + def extra_state_attributes(self) -> Dict[str, Any]: + state = self.hass.states.get(self.controller.power_sensor_id) + if not state: + return {"status": "missing", "entity_id": self.controller.power_sensor_id} + return { + "status": "available", + "entity_id": self.controller.power_sensor_id, + "unit": state.attributes.get("unit_of_measurement", ""), + "last_updated": state.last_updated.isoformat(), + } + + @property + def device_info(self) -> Dict[str, Any]: + return _device_info(self.config_entry, self.controller) diff --git a/custom_components/boiler_controller/services.yaml b/custom_components/boiler_controller/services.yaml deleted file mode 100644 index 7bd5eb9..0000000 --- a/custom_components/boiler_controller/services.yaml +++ /dev/null @@ -1,23 +0,0 @@ -run_calibration: - name: Run Calibration - description: | - Sweep the Shelly dimmer from 20% to 100% to build a per-boiler watt curve and store it for future calculations. - The controller steps in 1% increments and waits 3 seconds between samples. - fields: - config_entry_id: - name: Config Entry ID - description: Optional config_entry_id when multiple Boiler Controller entries exist. - example: "1234567890abcdef1234567890abcdef" - selector: - text: {} - -cancel_calibration: - name: Cancel Calibration - description: Request the active calibration sweep to stop after the current measurement. - fields: - config_entry_id: - name: Config Entry ID - description: Optional config_entry_id when multiple Boiler Controller entries exist. - example: "1234567890abcdef1234567890abcdef" - selector: - text: {} diff --git a/custom_components/boiler_controller/shelly_client.py b/custom_components/boiler_controller/shelly_client.py deleted file mode 100644 index 51ad2e8..0000000 --- a/custom_components/boiler_controller/shelly_client.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Client for interacting with a Shelly dimmer via RPC endpoints.""" -from __future__ import annotations - -import logging -from typing import Any, Dict, Optional - -import aiohttp - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import ( - SHELLY_RPC_DEVICE_INFO, - SHELLY_RPC_LIGHT_CONFIG, - SHELLY_RPC_LIGHT_SET, - SHELLY_RPC_LIGHT_STATUS, -) - -_LOGGER = logging.getLogger(__name__) - - -def normalize_device_id(device_id: str | None) -> str | None: - """Normalize Shelly device identifiers for comparisons.""" - if not device_id: - return None - return str(device_id).strip().lower() - - -class ShellyClient: - """Helper class to interact with Shelly RPC API.""" - - def __init__(self, hass: HomeAssistant, base_url: str, light_id: int = 0) -> None: - self.hass = hass - self.base_url = base_url.rstrip("/") - self._light_id = light_id - self._session = async_get_clientsession(hass) - - @staticmethod - def extract_device_id(payload: Dict[str, Any] | None) -> str | None: - """Extract and normalize the Shelly device identifier from RPC payloads.""" - if not payload: - return None - - candidate = ( - payload.get("id") - or payload.get("device_id") - or payload.get("mac") - or payload.get("name") - ) - return normalize_device_id(candidate) - - def _channel_params(self) -> Dict[str, int]: - """Return RPC params targeting the configured light channel.""" - channel_id = 0 if self._light_id is None else int(self._light_id) - return {"id": channel_id} - - async def async_get_status(self) -> Optional[Dict[str, Any]]: - """Fetch current status from the Shelly device.""" - url = f"{self.base_url}{SHELLY_RPC_LIGHT_STATUS}" - try: - async with self._session.get( - url, - params=self._channel_params(), - timeout=aiohttp.ClientTimeout(total=10), - ) as response: - if response.status == 200: - data = await response.json() - _LOGGER.debug("Shelly status: %s", data) - return data - _LOGGER.warning("Shelly status request failed with %s", response.status) - except aiohttp.ClientError as err: - _LOGGER.warning("Shelly status request error: %s", err) - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Unexpected Shelly status error: %s", err) - return None - - async def _async_light_set(self, payload: Dict[str, Any]) -> bool: - """Call the Shelly Light.Set RPC method via GET with query params.""" - url = f"{self.base_url}{SHELLY_RPC_LIGHT_SET}" - request_params = dict(self._channel_params()) - request_params.update(payload) - - safe_params: Dict[str, Any] = {} - for key, value in request_params.items(): - safe_params[key] = int(value) if isinstance(value, bool) else value - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Shelly Light.Set params=%s", safe_params) - - try: - async with self._session.get( - url, - params=safe_params, - timeout=aiohttp.ClientTimeout(total=10), - ) as response: - if response.status == 200: - return True - body = await response.text() - _LOGGER.warning( - "Shelly Light.Set failed with %s: %s", - response.status, - body.strip() or "", - ) - except aiohttp.ClientError as err: - _LOGGER.warning("Shelly Light.Set error: %s", err) - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Unexpected Shelly Light.Set error: %s", err) - return False - - async def async_set_brightness(self, brightness: int) -> bool: - """Set dimmer brightness (0-100).""" - clamped = max(0, min(100, int(brightness))) - payload = { - "brightness": clamped, - "on": bool(clamped), - } - return await self._async_light_set(payload) - - async def async_turn_off(self) -> bool: - """Turn off the Shelly dimmer.""" - return await self._async_light_set({"on": False}) - - async def async_test_connection(self) -> bool: - """Check whether the Shelly is reachable.""" - status = await self.async_get_status() - return status is not None - - async def async_get_light_config(self) -> Optional[Dict[str, Any]]: - """Fetch static configuration for the Shelly light channel.""" - url = f"{self.base_url}{SHELLY_RPC_LIGHT_CONFIG}" - try: - async with self._session.get( - url, - params=self._channel_params(), - timeout=aiohttp.ClientTimeout(total=10), - ) as response: - if response.status == 200: - data = await response.json() - _LOGGER.debug("Shelly light config: %s", data) - return data - body = await response.text() - _LOGGER.debug( - "Shelly light config request failed with %s: %s", - response.status, - body.strip() or "", - ) - except aiohttp.ClientError as err: - _LOGGER.debug("Shelly light config error: %s", err) - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Unexpected Shelly light config error: %s", err) - return None - - async def async_get_device_info(self) -> Optional[Dict[str, Any]]: - """Fetch general Shelly device information.""" - url = f"{self.base_url}{SHELLY_RPC_DEVICE_INFO}" - try: - async with self._session.get( - url, - timeout=aiohttp.ClientTimeout(total=10), - ) as response: - if response.status == 200: - data = await response.json() - _LOGGER.debug("Shelly device info: %s", data) - return data - body = await response.text() - _LOGGER.debug( - "Shelly device info request failed with %s: %s", - response.status, - body.strip() or "", - ) - except aiohttp.ClientError as err: - _LOGGER.debug("Shelly device info error: %s", err) - except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Unexpected Shelly device info error: %s", err) - return None diff --git a/custom_components/boiler_controller/translations/en.json b/custom_components/boiler_controller/translations/en.json index 7b33a23..b3da9a9 100644 --- a/custom_components/boiler_controller/translations/en.json +++ b/custom_components/boiler_controller/translations/en.json @@ -3,63 +3,46 @@ "step": { "user": { "title": "Setup Boiler Controller", - "description": "Select your power sensor and provide the Shelly device details.", + "description": "Give this integration a name.", "data": { "name": "Integration Name" } }, "power_sensor": { "title": "Select Power Sensor", - "description": "Choose the sensor that provides current power consumption/production data (in Watts)", + "description": "Choose the sensor that provides the current net grid power (in Watts). Negative = surplus/export, positive = import.", "data": { - "p1_total_entity": "Power Sensor" + "power_sensor": "Power Sensor" } }, - "shelly_config": { - "title": "Configure Shelly Device", - "description": "Enter the Shelly URL or IP address. If you started this flow from a discovered device we will pre-fill the field for you (e.g. {example_url1} or {example_url2}).", + "boiler_config": { + "title": "Configure Boiler Controller Module", + "description": "Enter the IP address or mDNS hostname of the boiler controller module (e.g. {example_host}).", "data": { - "shelly_url": "Shelly Base URL" + "boiler_host": "Boiler Module Host / IP" } } }, "error": { "no_power_sensors": "No power sensors found in Home Assistant", - "entity_not_found": "The selected entity was not found", - "no_dimmer_entities": "No light or input_number entities found in Home Assistant", - "invalid_url": "Provide a valid http(s) URL", - "cannot_connect": "Unable to reach the Shelly device", - "cannot_identify": "Unable to read the Shelly device identity", - "device_in_use": "This Shelly device is already configured" + "invalid_host": "Host cannot be empty", + "cannot_connect": "Unable to reach the boiler controller module" }, "abort": { - "already_configured": "Boiler Controller is already configured", - "unsupported_device": "The discovered device is not a supported Shelly dimmer" + "already_configured": "This boiler controller module is already configured", + "unsupported_device": "The discovered device is not a supported boiler controller module" } }, "options": { "step": { "init": { "title": "Boiler Controller Options", - "description": "Switch the power sensor or Shelly device used by this controller", + "description": "Update the module host or power sensor.", "data": { - "change_devices": "Change power sensor or Shelly settings" - } - }, - "power_sensor": { - "title": "Select New Power Sensor", - "description": "Choose a new sensor that provides current power consumption/production data (in Watts)", - "data": { - "p1_total_entity": "Power Sensor" - } - }, - "shelly_config": { - "title": "Update Shelly Settings", - "description": "Adjust the Shelly URL. If the device was discovered previously, the current URL is shown here.", - "data": { - "shelly_url": "Shelly Base URL" + "power_sensor": "Power Sensor", + "boiler_host": "Boiler Module Host / IP" } } } } -} \ No newline at end of file +} diff --git a/custom_components/boiler_controller/translations/nl.json b/custom_components/boiler_controller/translations/nl.json index eab4344..a9f7229 100644 --- a/custom_components/boiler_controller/translations/nl.json +++ b/custom_components/boiler_controller/translations/nl.json @@ -3,37 +3,33 @@ "step": { "user": { "title": "Boiler Controller Instellen", - "description": "Selecteer je vermogenssensor en vul de Shelly apparaatdetails in.", + "description": "Geef deze integratie een naam.", "data": { "name": "Integratie Naam" } }, "power_sensor": { - "title": "Selecteer Vermogen Sensor", - "description": "Kies de sensor die huidige vermogen verbruik/productie gegevens levert (in Watts)", + "title": "Selecteer Vermogenssensor", + "description": "Kies de sensor die het actuele netto netvermogen levert (in Watt). Negatief = surplus/teruglevering, positief = afname.", "data": { - "p1_total_entity": "Vermogen Sensor" + "power_sensor": "Vermogenssensor" } }, - "shelly_config": { - "title": "Shelly Configureren", - "description": "Vul de Shelly URL of IP adres in. Startte je deze flow vanuit een gevonden apparaat? Dan vullen we dit veld alvast voor je in (bijv. {example_url1} of {example_url2}).", + "boiler_config": { + "title": "Boiler Controller Module Configureren", + "description": "Vul het IP-adres of de mDNS-hostnaam in van de boiler controller module (bijv. {example_host}).", "data": { - "shelly_url": "Shelly Basis URL" + "boiler_host": "Module Host / IP" } } }, "error": { - "no_power_sensors": "Geen vermogen sensoren gevonden in Home Assistant", - "entity_not_found": "De geselecteerde entiteit werd niet gevonden", - "no_dimmer_entities": "Geen licht of input_number entiteiten gevonden in Home Assistant", - "invalid_url": "Geef een geldige http(s) URL op", - "cannot_connect": "Kan geen verbinding maken met het Shelly apparaat", - "cannot_identify": "Kan het Shelly apparaat ID niet uitlezen", - "device_in_use": "Deze Shelly is al geconfigureerd" + "no_power_sensors": "Geen vermogenssensoren gevonden in Home Assistant", + "invalid_host": "Host mag niet leeg zijn", + "cannot_connect": "Kan geen verbinding maken met de boiler controller module" }, "abort": { - "already_configured": "Boiler Controller is al geconfigureerd", + "already_configured": "Deze boiler controller module is al geconfigureerd", "unsupported_device": "Het gevonden apparaat wordt niet ondersteund" } }, @@ -41,25 +37,12 @@ "step": { "init": { "title": "Boiler Controller Opties", - "description": "Wijzig de vermogenssensor of Shelly die deze controller gebruikt", + "description": "Pas de module host of vermogenssensor aan.", "data": { - "change_devices": "Wijzig vermogenssensor of Shelly instellingen" - } - }, - "power_sensor": { - "title": "Selecteer Nieuwe Stroommeter", - "description": "Kies een nieuwe sensor die huidige stroomverbruik/productie data levert (in Watts)", - "data": { - "p1_total_entity": "Stroommeter" - } - }, - "shelly_config": { - "title": "Shelly Instellingen Bijwerken", - "description": "Pas de Shelly URL aan. Als het apparaat eerder gevonden werd, tonen we hier de huidige waarde.", - "data": { - "shelly_url": "Shelly Basis URL" + "power_sensor": "Vermogenssensor", + "boiler_host": "Module Host / IP" } } } } -} \ No newline at end of file +}