From 1a5c37cb74c71450f6124b48fc847d362daa2f17 Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Tue, 30 Sep 2025 22:59:18 +0200 Subject: [PATCH 01/12] feat: added basic integration --- .github/workflows/hassfest.yaml | 14 + .github/workflows/validate.yml | 18 + README.md | 50 +- custom_components/.DS_Store | Bin 0 -> 6148 bytes .../boiler_controller/__init__.py | 67 ++ .../boiler_controller/config_flow.py | 463 ++++++++++++++ custom_components/boiler_controller/const.py | 39 ++ .../boiler_controller/controller.py | 553 +++++++++++++++++ .../boiler_controller/dimmer_calculator.py | 28 + .../boiler_controller/manifest.json | 18 + custom_components/boiler_controller/number.py | 70 +++ custom_components/boiler_controller/select.py | 67 ++ custom_components/boiler_controller/sensor.py | 578 ++++++++++++++++++ .../boiler_controller/shelly_client.py | 165 +++++ .../boiler_controller/translations/en.json | 67 ++ .../boiler_controller/translations/nl.json | 67 ++ hacs.json | 5 + 17 files changed, 2267 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/hassfest.yaml create mode 100644 .github/workflows/validate.yml create mode 100644 custom_components/.DS_Store create mode 100644 custom_components/boiler_controller/__init__.py create mode 100644 custom_components/boiler_controller/config_flow.py create mode 100644 custom_components/boiler_controller/const.py create mode 100644 custom_components/boiler_controller/controller.py create mode 100644 custom_components/boiler_controller/dimmer_calculator.py create mode 100644 custom_components/boiler_controller/manifest.json create mode 100644 custom_components/boiler_controller/number.py create mode 100644 custom_components/boiler_controller/select.py create mode 100644 custom_components/boiler_controller/sensor.py create mode 100644 custom_components/boiler_controller/shelly_client.py create mode 100644 custom_components/boiler_controller/translations/en.json create mode 100644 custom_components/boiler_controller/translations/nl.json create mode 100644 hacs.json diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml new file mode 100644 index 0000000..9984579 --- /dev/null +++ b/.github/workflows/hassfest.yaml @@ -0,0 +1,14 @@ +name: Validate with hassfest + +on: + push: + pull_request: + # schedule: + # - cron: "0 0 * * *" + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v3" + - uses: home-assistant/actions/hassfest@master \ No newline at end of file diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..ffd3ede --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,18 @@ +name: Validate + +on: + push: + pull_request: + # schedule: + # - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v4" + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" diff --git a/README.md b/README.md index 55a3ace..003c632 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,49 @@ -# Boiler Controller HA integration +# Boiler Controller HA Integration -[TODO] \ No newline at end of file +A Home Assistant integration for automatically controlling a Shelly Dimmer 0/1-10V PM Gen3 based on P1 smart meter data. + +## Features + +This integration: +- Reads data from a P1 smart meter via an existing Home Assistant device +- Controls a Shelly Dimmer 0/1-10V PM Gen3 based on live net consumption +- Automatically switches between different dimmer percentages depending on consumption +- Provides Shelly telemetry sensors (voltage, current, power, temperature, energy) +- Exposes manual override entities so you can switch between automatic logic and a fixed brightness when needed + +## Installation + +1. Install via HACS or copy the `custom_components/boiler_controller` folder to your Home Assistant configuration +2. Restart Home Assistant +3. Go to Settings > Devices & Services +4. Click "Add Integration" and search for "Boiler Controller" +5. Follow the configuration steps: + - Select your P1 smart meter device + - Choose the correct power entity from the P1 meter + - Select your Shelly Dimmer device + +## Configuration + +The integration requires: +- A working P1 smart meter integration in Home Assistant +- A Shelly Dimmer 0/1-10V PM Gen3 device connected to Home Assistant + +## Advanced Settings & Manual Override + +Via the integration options you can adjust the minimum and maximum dimmer bounds that the automatic logic uses. + +For ad-hoc control you also get two helper entities once the integration is set up: + +- `Select` – **{Integration Name} Dimmer Mode**: choose `auto` to let the controller react to power usage, or `manual` to override the Shelly brightness yourself. +- `Number` – **{Integration Name} Manual Brightness**: specify the brightness percentage (0–100). This value is only applied when the mode select is in `manual`. + +Switching back to `auto` immediately returns control to the P1-driven logic. + +## Logic + +The default logic: +- At 0W consumption: dimmer at minimum +- At 3000W+ consumption: dimmer at maximum +- In between: linearly scaled between min and max + +This logic can be customized in the `controller.py` file. \ No newline at end of file diff --git a/custom_components/.DS_Store b/custom_components/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e87039072a7e579b91d3979523a0513e8f09ac13 GIT binary patch literal 6148 zcmeHKQA@)x5WZ~FbqsM2Dn1r`9k|I5#g{VYAF!eiDzl|Ui?tbTXOS`Jqy8a(ioeIZ zBooIJ9|VzcH!k1ha+i=VBi8_c=#PUgKnnmYRKiLFn=gdMNf)GIJcL5ck-`uH@ZbsZ zg=lvCM+WHZ)*%B4F`nD!^OG>bUkIsRWFtS8K1RO!qd3a)PUlTjsdKD*0p;l z3pe-jan|?JOX{6T8T&Ka^UtHO7`8T!Ws-YQ5~eC43PKFIx`>iM7JWHRf=uPQdcbN} zjbUqRGU>GK?!o@FZBGvO(C+Q`rqhPCxwCt8I=qh`68Wr&75E=ivSx4wFKGO*$456! zVwv1x bool: + """Set up Boiler Controller from a config entry.""" + _LOGGER.info("Setting up Boiler Controller") + + integration = await async_get_integration(hass, DOMAIN) + integration_version = integration.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, + } + + # Set up platforms + 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) + + return unload_ok + + +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) diff --git a/custom_components/boiler_controller/config_flow.py b/custom_components/boiler_controller/config_flow.py new file mode 100644 index 0000000..0c50b4a --- /dev/null +++ b/custom_components/boiler_controller/config_flow.py @@ -0,0 +1,463 @@ +import logging + +import aiohttp +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from homeassistant.core import callback +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + DOMAIN, + CONF_P1_TOTAL_ENTITY, + CONF_SHELLY_URL, + CONF_SHELLY_ID, + SHELLY_DIMMER_HOST_PREFIX, +) +from .shelly_client import ShellyClient + +_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.""" + if not device_id: + return None + + normalized = device_id.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: + return entry + + if entry.unique_id and entry.unique_id.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 None + + device_id = ShellyClient.extract_device_id(payload) + if not device_id: + _LOGGER.warning("Unable to extract Shelly ID from payload: %s", payload) + return device_id + + +class BoilerControllerConfigFlow(ShellyValidationMixin, config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 4 + + def __init__(self): + self.data = {} + + 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") + + hostname = hostname.rstrip('.') + short_hostname = hostname.split('.')[0].lower() + if not short_hostname.startswith(SHELLY_DIMMER_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 + + 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 + + existing_entry = _find_config_entry_for_device(self.hass, unique_id) + if existing_entry: + 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.data[CONF_SHELLY_URL] = shelly_url + self.data[CONF_SHELLY_ID] = unique_id + self.context["title_placeholders"] = {"device": mdns_host} + + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + _LOGGER.debug("Boiler Controller config flow started") + + errors = {} + + 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 + ) + + async def async_step_power_sensor(self, user_input=None): + """Handle power sensor selection.""" + errors = {} + + if user_input is not None: + # Store power sensor selection + self.data.update(user_input) + return await self.async_step_shelly_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 + ) + + async def async_step_shelly_config(self, user_input=None): + """Handle Shelly connection configuration.""" + errors = {} + + stored_url = self.data.get(CONF_SHELLY_URL) + default_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, "")) + + if not shelly_url.startswith(("http://", "https://")): + errors[CONF_SHELLY_URL] = "invalid_url" + 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" + + default_url = shelly_url + + schema = vol.Schema({ + vol.Required(CONF_SHELLY_URL, default=default_url): str + }) + + return self.async_show_form( + step_id="shelly_config", + data_schema=schema, + errors=errors + ) + + async def _get_power_sensors(self): + """Get list of power sensors.""" + sensors = {} + + 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 + 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}]" + except (ValueError, TypeError): + continue + + 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.""" + + def __init__(self, config_entry): + super().__init__() + self._config_entry = config_entry + self.data = {} + + 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() + else: + # Only update the advanced settings + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema({ + vol.Optional("change_devices", default=False): bool, + vol.Optional("min_dimmer_value", default=self._config_entry.options.get("min_dimmer_value", 0)): int, + vol.Optional("max_dimmer_value", default=self._config_entry.options.get("max_dimmer_value", 100)): int, + }) + ) + + 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() + + # 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( + 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 + ) + + 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, + errors=errors, + ) + + async def _get_power_sensors(self): + """Get list of power sensors.""" + sensors = {} + + 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 + 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}]" + except (ValueError, TypeError): + continue + + 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 new file mode 100644 index 0000000..b294137 --- /dev/null +++ b/custom_components/boiler_controller/const.py @@ -0,0 +1,39 @@ +DOMAIN = "boiler_controller" +VERSION = "0.1.0" + +PLATFORMS = ["sensor", "select", "number"] + +# Configuration flow step IDs +STEP_POWER_SENSOR = "power_sensor" +STEP_SHELLY_CONFIG = "shelly_config" + +# 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" + +# 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-") + +# Default settings for the controller +DEFAULT_MIN_DIMMER_VALUE = 0 +DEFAULT_MAX_DIMMER_VALUE = 100 +# Manual override defaults and modes +DEFAULT_MANUAL_BRIGHTNESS = 0 +DIMMER_MODE_AUTO = "auto" +DIMMER_MODE_MANUAL = "manual" +DIMMER_MODES = [DIMMER_MODE_AUTO, DIMMER_MODE_MANUAL] +# Throttle between Shelly brightness updates to avoid spamming the dimmer when the P1 sensor flaps +DEFAULT_MIN_UPDATE_INTERVAL = 2 +# Seconds between Shelly status polls +# Where it updates all the sensor values +DEFAULT_SHELLY_POLL_INTERVAL = 15 \ No newline at end of file diff --git a/custom_components/boiler_controller/controller.py b/custom_components/boiler_controller/controller.py new file mode 100644 index 0000000..a72f9a5 --- /dev/null +++ b/custom_components/boiler_controller/controller.py @@ -0,0 +1,553 @@ +import logging +import asyncio + +from homeassistant.core import HomeAssistant, Event, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.util import dt as dt_util + +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_MIN_UPDATE_INTERVAL, + DEFAULT_SHELLY_POLL_INTERVAL, + DEFAULT_MANUAL_BRIGHTNESS, + DIMMER_MODE_AUTO, + DIMMER_MODE_MANUAL, + DIMMER_MODES, +) +from .shelly_client import ShellyClient +from .dimmer_calculator import DimmerCalculator + +_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): + self.hass = hass + self.config_entry = config_entry + self.integration_version = integration_version + self._cancel_listener = None + self._poll_task = None + self._last_update = None + self._last_power_value = None + self._shelly_status = 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" + + # 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._dimmer_calculator = DimmerCalculator() + 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(0, min(100, int(stored_manual))) + + # Options + self.min_dimmer_value = config_entry.options.get("min_dimmer_value", DEFAULT_MIN_DIMMER_VALUE) + self.max_dimmer_value = config_entry.options.get("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) + ) + + # Throttling to prevent too frequent updates (configurable, default 2 seconds) + self.min_update_interval = DEFAULT_MIN_UPDATE_INTERVAL + + self._recompute_effective_dimmer_bounds() + + _LOGGER.debug( + "Initialized BoilerController: Power Sensor=%s, Shelly URL=%s, min_update_interval=%ds, poll_interval=%ds", + self.power_sensor_id, + self.shelly_url, + self.min_update_interval, + self.shelly_poll_interval, + ) + + async def async_start(self): + """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() + else: + _LOGGER.warning("Unable to reach Shelly device at %s during startup", self.shelly_url) + + # Start listening to power sensor state changes + self._cancel_listener = async_track_state_change_event( + self.hass, + [self.power_sensor_id], + self._async_power_sensor_changed + ) + _LOGGER.info("Started listening to power sensor state changes for: %s", self.power_sensor_id) + + # 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) + await self._async_update() + + _LOGGER.info("Boiler Controller started successfully") + return True + + @callback + async def _async_power_sensor_changed(self, event: Event): + """Handle power sensor state changes.""" + 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", + ) + return + + _LOGGER.info("Power sensor state change event received for %s: old=%s, new=%s", + self.power_sensor_id, + old_state.state if old_state else "None", + new_state.state if new_state else "None") + + if not new_state: + 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") + return + + # Parse and validate the new power value first + try: + raw_power_value = 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 + + # Throttle updates to prevent too frequent changes (only after we know we need to update) + now = dt_util.utcnow() + if self._last_update and (now - self._last_update).total_seconds() < self.min_update_interval: + _LOGGER.debug("Throttling update - %.1f seconds since last update (min: %d)", + (now - self._last_update).total_seconds(), self.min_update_interval) + 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() + + 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 + + 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 + + async def _async_update(self, *args): + """Update the controller - read P1 data and adjust dimmer.""" + 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 self._dimming_mode == DIMMER_MODE_MANUAL: + _LOGGER.debug("Manual dimmer mode active - skipping automatic adjustment") + return + + # Calculate dimmer percentage based on power value + dimmer_percentage = self._dimmer_calculator.calculate( + power_value, + self._effective_min_dimmer_value, + self._effective_max_dimmer_value, + ) + _LOGGER.debug("Calculated dimmer percentage: %s%%", dimmer_percentage) + + # Update dimmer + await self._set_dimmer_percentage(dimmer_percentage, source=DIMMER_MODE_AUTO) + + self._last_update = dt_util.utcnow() + + 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: + status = await self.shelly_client.async_get_status() + if status is not None: + self._shelly_status = 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 _set_dimmer_percentage(self, percentage: int, *, source: str = DIMMER_MODE_AUTO): + """Set the dimmer to the specified percentage using Shelly API.""" + try: + context = "manual override" if source == DIMMER_MODE_MANUAL else "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() + 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: + if source == DIMMER_MODE_MANUAL: + _LOGGER.info( + "Shelly dimmer request (%s): set to %s%%", + context, + percentage, + ) + else: + _LOGGER.info( + "Shelly dimmer request (%s): set to %s%% (effective range %s-%s%%)", + context, + percentage, + self._effective_min_dimmer_value, + self._effective_max_dimmer_value, + ) + success = await self.shelly_client.async_set_brightness(percentage) + if success: + _LOGGER.debug("Shelly dimmer set to %s%%", percentage) + else: + _LOGGER.warning("Failed to set Shelly dimmer to %s%%", 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.""" + return { + "identifiers": {(DOMAIN, self.config_entry.entry_id)}, + "name": self.config_entry.title, + "manufacturer": "Boiler Controller", + "model": "P1 to Dimmer Controller", + "sw_version": self.integration_version or str(self.config_entry.version), + } + + def get_status(self): + """Get current controller status.""" + return { + "last_update": self._last_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", + "min_update_interval": self.min_update_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, + } + + def get_shelly_status(self): + """Expose latest Shelly polling data.""" + return self._shelly_status + + def get_shelly_status_signal(self): + """Return dispatcher signal name for Shelly status updates.""" + return self._dispatcher_signal + + def get_dimming_mode_signal(self): + """Dispatcher signal for dimming mode changes.""" + return self._mode_signal + + def get_manual_brightness_signal(self): + """Dispatcher signal for manual brightness changes.""" + return self._manual_brightness_signal + + @property + def dimming_mode(self) -> str: + return self._dimming_mode + + @property + def manual_brightness(self) -> int: + return self._manual_brightness + + async def async_set_dimming_mode(self, mode: str): + """Set dimming mode to auto or manual.""" + if mode not in (DIMMER_MODE_AUTO, DIMMER_MODE_MANUAL): + raise ValueError(f"Unsupported dimming mode: {mode}") + if mode == self._dimming_mode: + return + + self._dimming_mode = mode + self._persist_controller_options(dimming_mode=mode) + async_dispatcher_send(self.hass, self._mode_signal, mode) + + if mode == DIMMER_MODE_MANUAL: + await self._apply_manual_brightness() + else: + await self._async_update() + + async def async_set_manual_brightness(self, brightness: int): + """Store manual brightness and apply when manual mode is active.""" + brightness = max(0, 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_update = dt_util.utcnow() + + 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 _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): + """Combine user-configured bounds with Shelly hardware limits.""" + new_min = self.min_dimmer_value + 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 + 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): + _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, + ) + + 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: + return None + + return min(matches) if limit_type == "min" else max(matches) + + @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 + + 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 diff --git a/custom_components/boiler_controller/dimmer_calculator.py b/custom_components/boiler_controller/dimmer_calculator.py new file mode 100644 index 0000000..a61f86d --- /dev/null +++ b/custom_components/boiler_controller/dimmer_calculator.py @@ -0,0 +1,28 @@ +"""Logic for translating power readings into Shelly dimmer percentages.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class DimmerCalculator: + """Encapsulate the dimmer percentage calculation logic.""" + + max_power_watts: float = 3000.0 + + def calculate(self, power_value: float, min_dimmer: int, max_dimmer: int) -> int: + """Return the dimmer percentage for the given power value.""" + return 0 + # if power_value <= 0: + # return 0 + + # # Ensure bounds are valid before interpolating + # min_dimmer = max(0, min(100, int(min_dimmer))) + # max_dimmer = max(min_dimmer, min(100, int(max_dimmer))) + + # if power_value >= self.max_power_watts: + # return max_dimmer + + # scale = max(0.0, min(1.0, power_value / self.max_power_watts)) + # percentage = int(min_dimmer + (max_dimmer - min_dimmer) * scale) + # return max(min_dimmer, min(max_dimmer, percentage)) diff --git a/custom_components/boiler_controller/manifest.json b/custom_components/boiler_controller/manifest.json new file mode 100644 index 0000000..dea0833 --- /dev/null +++ b/custom_components/boiler_controller/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "boiler_controller", + "name": "Boiler Controller", + "codeowners": ["@reinos", "@XiloXL"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/BoilerController/boiler-controller-ha", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/BoilerController/boiler-controller-ha/issues", + "requirements": [], + "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-*" } + ], + "version": "0.1.0" +} diff --git a/custom_components/boiler_controller/number.py b/custom_components/boiler_controller/number.py new file mode 100644 index 0000000..b42615c --- /dev/null +++ b/custom_components/boiler_controller/number.py @@ -0,0 +1,70 @@ +"""Number entities for controlling manual brightness.""" +from __future__ import annotations + +from homeassistant.components.number import NumberEntity, NumberMode +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 .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + 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)]) + + +class BoilerControllerManualBrightnessNumber(NumberEntity): + """Number entity exposing manual brightness override.""" + + _attr_should_poll = False + _attr_native_min_value = 0 + _attr_native_max_value = 100 + _attr_native_step = 1 + _attr_mode = NumberMode.BOX + _attr_icon = "mdi:brightness-percent" + + 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._remove_dispatcher = None + + async def async_added_to_hass(self) -> None: + self._remove_dispatcher = async_dispatcher_connect( + self.hass, + self.controller.get_manual_brightness_signal(), + self._handle_manual_brightness_update, + ) + + async def async_will_remove_from_hass(self) -> None: + if self._remove_dispatcher: + self._remove_dispatcher() + self._remove_dispatcher = None + + @callback + def _handle_manual_brightness_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: + await self.controller.async_set_manual_brightness(int(value)) + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self.config_entry.entry_id)}, + "name": self.config_entry.title, + "manufacturer": "Boiler Controller", + "model": "P1 to Shelly Controller", + "sw_version": self.controller.integration_version or str(self.config_entry.version), + } diff --git a/custom_components/boiler_controller/select.py b/custom_components/boiler_controller/select.py new file mode 100644 index 0000000..ed300a1 --- /dev/null +++ b/custom_components/boiler_controller/select.py @@ -0,0 +1,67 @@ +"""Select entities for the Boiler Controller integration.""" +from __future__ import annotations + +from homeassistant.components.select import SelectEntity +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 .const import DOMAIN, DIMMER_MODES + + +async def async_setup_entry( + hass: HomeAssistant, + 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)]) + + +class BoilerControllerModeSelect(SelectEntity): + """Select entity toggling automatic/manual dimming.""" + + _attr_should_poll = False + _attr_options = DIMMER_MODES + _attr_icon = "mdi:lightning-bolt-outline" + + 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._remove_dispatcher = None + + async def async_added_to_hass(self) -> None: + self._remove_dispatcher = async_dispatcher_connect( + self.hass, + self.controller.get_dimming_mode_signal(), + self._handle_mode_update, + ) + + async def async_will_remove_from_hass(self) -> None: + if self._remove_dispatcher: + self._remove_dispatcher() + self._remove_dispatcher = None + + @callback + def _handle_mode_update(self, mode: str) -> None: + self._attr_current_option = mode + self.async_write_ha_state() + + async def async_select_option(self, option: str) -> None: + await self.controller.async_set_dimming_mode(option) + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self.config_entry.entry_id)}, + "name": self.config_entry.title, + "manufacturer": "Boiler Controller", + "model": "P1 to Shelly Controller", + "sw_version": self.controller.integration_version or str(self.config_entry.version), + } diff --git a/custom_components/boiler_controller/sensor.py b/custom_components/boiler_controller/sensor.py new file mode 100644 index 0000000..c0c872e --- /dev/null +++ b/custom_components/boiler_controller/sensor.py @@ -0,0 +1,578 @@ +import logging +from typing import Any, Callable, Dict, List, Optional + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +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.util import dt as dt_util + +try: + from homeassistant.const import ( + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotentialDifference, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, + ) + + UNIT_CURRENT = UnitOfElectricCurrent.AMPERE + UNIT_VOLTAGE = UnitOfElectricPotentialDifference.VOLT + UNIT_POWER = UnitOfPower.WATT + UNIT_TEMP = UnitOfTemperature.CELSIUS + UNIT_ENERGY = UnitOfEnergy.KILO_WATT_HOUR +except ImportError: + from homeassistant.const import PERCENTAGE + + UNIT_CURRENT = "A" + UNIT_VOLTAGE = "V" + UNIT_POWER = "W" + UNIT_TEMP = "°C" + UNIT_ENERGY = "kWh" + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def _integration_version(controller, config_entry: ConfigEntry) -> str: + return controller.integration_version or str(config_entry.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", + "sw_version": version, + } + + +async def async_setup_entry( + hass: HomeAssistant, + 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), + LastUpdateSensor(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), + ] + + async_add_entities(sensors) + + +class BoilerControllerStatusSensor(SensorEntity): + """High-level status sensor for the controller.""" + + _attr_should_poll = False + + 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} Status" + self._attr_unique_id = f"{config_entry.entry_id}_status" + self._attr_icon = "mdi:thermostat" + 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_power_update, + ) + ) + self._remove_callbacks.append( + async_dispatcher_connect( + self.hass, + self.controller.get_shelly_status_signal(), + self._handle_shelly_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_power_update(self, event) -> None: + self.async_write_ha_state() + + @callback + def _handle_shelly_update(self, status) -> None: + self.async_write_ha_state() + + @property + def state(self) -> str: + return "Active" if self.controller._last_update else "Waiting" + + @property + def extra_state_attributes(self) -> Dict[str, Any]: + attrs: Dict[str, Any] = { + "power_sensor": self.controller.power_sensor_id, + "shelly_url": self.controller.shelly_url, + "min_update_interval": f"{self.controller.min_update_interval}s", + "shelly_poll_interval": f"{self.controller.shelly_poll_interval}s", + "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"), + } + ) + + power_state = self.hass.states.get(self.controller.power_sensor_id) + if power_state: + attrs.update( + { + "power_sensor_status": "available", + "power_sensor_value": power_state.state, + "power_sensor_unit": power_state.attributes.get("unit_of_measurement", "W"), + } + ) + else: + attrs["power_sensor_status"] = "missing" + + status = self.controller.get_shelly_status() + if status: + 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")), + } + ) + 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_update: + attrs["last_update"] = self.controller._last_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) + + +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" + + 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] = [] + + 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.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]: + 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 LastUpdateSensor(SensorEntity): + """Sensor showing when the controller last updated Shelly.""" + + _attr_should_poll = False + _attr_device_class = SensorDeviceClass.TIMESTAMP + + 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 Update" + self._attr_unique_id = f"{config_entry.entry_id}_last_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_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]: + attrs = { + "min_update_interval": f"{self.controller.min_update_interval}s", + "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) + + +class ShellySensorBase(SensorEntity): + """Base class for Shelly telemetry sensors fed by the controller polling loop.""" + + _attr_should_poll = False + + 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 + + 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, + ) + 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) + + +class ShellyBrightnessSensor(ShellySensorBase): + """Expose Shelly brightness (percentage).""" + + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + 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", + ) + + 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 + + +class ShellyVoltageSensor(ShellySensorBase): + """Expose Shelly reported voltage.""" + + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_native_unit_of_measurement = UNIT_VOLTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + 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", + ) + + 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 + + +class ShellyCurrentSensor(ShellySensorBase): + """Expose Shelly reported current.""" + + _attr_device_class = SensorDeviceClass.CURRENT + _attr_native_unit_of_measurement = UNIT_CURRENT + _attr_state_class = SensorStateClass.MEASUREMENT + + 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", + ) + + 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 + + +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 + + 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", + ) + + 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 + + +class ShellyTemperatureSensor(ShellySensorBase): + """Expose Shelly internal temperature.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UNIT_TEMP + _attr_state_class = SensorStateClass.MEASUREMENT + + 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", + ) + + 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 + + +class ShellyEnergySensor(ShellySensorBase): + """Expose Shelly cumulative energy in kWh.""" + + _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", + ) + + 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 diff --git a/custom_components/boiler_controller/shelly_client.py b/custom_components/boiler_controller/shelly_client.py new file mode 100644 index 0000000..630293a --- /dev/null +++ b/custom_components/boiler_controller/shelly_client.py @@ -0,0 +1,165 @@ +"""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.""" + return {"id": self._light_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, params: Dict[str, Any]) -> bool: + """Call the Shelly Light.Set RPC method via POST.""" + url = f"{self.base_url}{SHELLY_RPC_LIGHT_SET}" + try: + async with self._session.post( + url, + json=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))) + params = self._channel_params() + params["brightness"] = clamped + params["on"] = bool(clamped) + return await self._async_light_set(params) + + async def async_turn_off(self) -> bool: + """Turn off the Shelly dimmer.""" + params = self._channel_params() + params["on"] = False + return await self._async_light_set(params) + + 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 new file mode 100644 index 0000000..28a86d0 --- /dev/null +++ b/custom_components/boiler_controller/translations/en.json @@ -0,0 +1,67 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup Boiler Controller", + "description": "Select your power sensor and provide the Shelly device details.", + "data": { + "name": "Integration Name" + } + }, + "power_sensor": { + "title": "Select Power Sensor", + "description": "Choose the sensor that provides current power consumption/production data (in Watts)", + "data": { + "p1_total_entity": "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. http://shelly0110dimg3-xxxx.local or http://shellyplusdimg3-xxxx.local).", + "data": { + "shelly_url": "Shelly Base URL" + } + } + }, + "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" + }, + "abort": { + "already_configured": "Boiler Controller is already configured", + "unsupported_device": "The discovered device is not a supported Shelly dimmer" + } + }, + "options": { + "step": { + "init": { + "title": "Boiler Controller Options", + "description": "Configure advanced settings for the boiler controller", + "data": { + "change_devices": "Change power sensor or Shelly settings", + "min_dimmer_value": "Minimum Dimmer Value (%)", + "max_dimmer_value": "Maximum Dimmer Value (%)" + } + }, + "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" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/boiler_controller/translations/nl.json b/custom_components/boiler_controller/translations/nl.json new file mode 100644 index 0000000..4f8ef05 --- /dev/null +++ b/custom_components/boiler_controller/translations/nl.json @@ -0,0 +1,67 @@ +{ + "config": { + "step": { + "user": { + "title": "Boiler Controller Instellen", + "description": "Selecteer je vermogenssensor en vul de Shelly apparaatdetails in.", + "data": { + "name": "Integratie Naam" + } + }, + "power_sensor": { + "title": "Selecteer Vermogen Sensor", + "description": "Kies de sensor die huidige vermogen verbruik/productie gegevens levert (in Watts)", + "data": { + "p1_total_entity": "Vermogen Sensor" + } + }, + "shelly_config": { + "title": "Shelly Configureren", + "description": "Vul de Shelly RPC basis URL in. Startte je deze flow vanuit een gevonden apparaat? Dan vullen we dit veld alvast voor je in (bijv. http://shelly0110dimg3-xxxx.local of http://shellyplusdimg3-xxxx.local).", + "data": { + "shelly_url": "Shelly Basis URL" + } + } + }, + "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" + }, + "abort": { + "already_configured": "Boiler Controller is al geconfigureerd", + "unsupported_device": "Het gevonden apparaat wordt niet ondersteund" + } + }, + "options": { + "step": { + "init": { + "title": "Boiler Controller Opties", + "description": "Configureer geavanceerde instellingen voor de boiler controller", + "data": { + "change_devices": "Wijzig vermogenssensor of Shelly instellingen", + "min_dimmer_value": "Minimale Dimmer Waarde (%)", + "max_dimmer_value": "Maximale Dimmer Waarde (%)" + } + }, + "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" + } + } + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..8274feb --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Boiler Controller", + "homeassistant": "2024.1.0", + "render_readme": true +} \ No newline at end of file From 1cc64facde5b8e3620df9ab090db1f3c39f136f6 Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Tue, 13 Jan 2026 21:18:16 +0100 Subject: [PATCH 02/12] WIP --- .../{dimmer_calculator.py => calculator.py} | 2 +- custom_components/boiler_controller/controller.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename custom_components/boiler_controller/{dimmer_calculator.py => calculator.py} (97%) diff --git a/custom_components/boiler_controller/dimmer_calculator.py b/custom_components/boiler_controller/calculator.py similarity index 97% rename from custom_components/boiler_controller/dimmer_calculator.py rename to custom_components/boiler_controller/calculator.py index a61f86d..e371b3a 100644 --- a/custom_components/boiler_controller/dimmer_calculator.py +++ b/custom_components/boiler_controller/calculator.py @@ -5,7 +5,7 @@ @dataclass(slots=True) -class DimmerCalculator: +class Calculator: """Encapsulate the dimmer percentage calculation logic.""" max_power_watts: float = 3000.0 diff --git a/custom_components/boiler_controller/controller.py b/custom_components/boiler_controller/controller.py index a72f9a5..45c8f7a 100644 --- a/custom_components/boiler_controller/controller.py +++ b/custom_components/boiler_controller/controller.py @@ -22,7 +22,7 @@ DIMMER_MODES, ) from .shelly_client import ShellyClient -from .dimmer_calculator import DimmerCalculator +from .calculator import Calculator _LOGGER = logging.getLogger(__name__) @@ -47,7 +47,7 @@ def __init__(self, hass: HomeAssistant, config_entry, integration_version: str | 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._dimmer_calculator = DimmerCalculator() + 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) @@ -224,7 +224,7 @@ async def _async_update(self, *args): return # Calculate dimmer percentage based on power value - dimmer_percentage = self._dimmer_calculator.calculate( + dimmer_percentage = self._calculator.calculate( power_value, self._effective_min_dimmer_value, self._effective_max_dimmer_value, From 30642181811f953f4cb55b5a1ed3dc6799297ef2 Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Tue, 13 Jan 2026 22:24:37 +0100 Subject: [PATCH 03/12] WIP --- custom_components/boiler_controller/const.py | 4 +- .../boiler_controller/controller.py | 46 +++++++++---------- custom_components/boiler_controller/sensor.py | 5 +- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/custom_components/boiler_controller/const.py b/custom_components/boiler_controller/const.py index b294137..4732286 100644 --- a/custom_components/boiler_controller/const.py +++ b/custom_components/boiler_controller/const.py @@ -32,8 +32,8 @@ DIMMER_MODE_AUTO = "auto" DIMMER_MODE_MANUAL = "manual" DIMMER_MODES = [DIMMER_MODE_AUTO, DIMMER_MODE_MANUAL] -# Throttle between Shelly brightness updates to avoid spamming the dimmer when the P1 sensor flaps -DEFAULT_MIN_UPDATE_INTERVAL = 2 +# 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 \ No newline at end of file diff --git a/custom_components/boiler_controller/controller.py b/custom_components/boiler_controller/controller.py index 45c8f7a..5b9ef55 100644 --- a/custom_components/boiler_controller/controller.py +++ b/custom_components/boiler_controller/controller.py @@ -14,7 +14,7 @@ CONF_SHELLY_POLL_INTERVAL, DEFAULT_MIN_DIMMER_VALUE, DEFAULT_MAX_DIMMER_VALUE, - DEFAULT_MIN_UPDATE_INTERVAL, + DEFAULT_CALCULATOR_MIN_INTERVAL, DEFAULT_SHELLY_POLL_INTERVAL, DEFAULT_MANUAL_BRIGHTNESS, DIMMER_MODE_AUTO, @@ -38,6 +38,7 @@ def __init__(self, hass: HomeAssistant, config_entry, integration_version: str | self._poll_task = None self._last_update = None self._last_power_value = None + self._last_calculator_run = None self._shelly_status = None self._dispatcher_signal = f"{DOMAIN}_{config_entry.entry_id}_shelly_status" self._mode_signal = f"{DOMAIN}_{config_entry.entry_id}_dimming_mode" @@ -65,16 +66,13 @@ def __init__(self, hass: HomeAssistant, config_entry, integration_version: str | config_entry.data.get(CONF_SHELLY_POLL_INTERVAL, DEFAULT_SHELLY_POLL_INTERVAL) ) - # Throttling to prevent too frequent updates (configurable, default 2 seconds) - self.min_update_interval = DEFAULT_MIN_UPDATE_INTERVAL - self._recompute_effective_dimmer_bounds() _LOGGER.debug( - "Initialized BoilerController: Power Sensor=%s, Shelly URL=%s, min_update_interval=%ds, poll_interval=%ds", + "Initialized BoilerController: Power Sensor=%s, Shelly URL=%s, throttle_interval=%ds, poll_interval=%ds", self.power_sensor_id, self.shelly_url, - self.min_update_interval, + DEFAULT_CALCULATOR_MIN_INTERVAL, self.shelly_poll_interval, ) @@ -114,6 +112,12 @@ async def async_start(self): @callback async def _async_power_sensor_changed(self, event: Event): """Handle power sensor state changes.""" + 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: + return + new_state = event.data.get("new_state") old_state = event.data.get("old_state") @@ -125,11 +129,6 @@ async def _async_power_sensor_changed(self, event: Event): ) return - _LOGGER.info("Power sensor state change event received for %s: old=%s, new=%s", - self.power_sensor_id, - old_state.state if old_state else "None", - new_state.state if new_state else "None") - if not new_state: return @@ -154,13 +153,6 @@ async def _async_power_sensor_changed(self, event: Event): _LOGGER.debug("Skipping update - power change too small: %.1fW", abs(new_power_value - self._last_power_value)) return - # Throttle updates to prevent too frequent changes (only after we know we need to update) - now = dt_util.utcnow() - if self._last_update and (now - self._last_update).total_seconds() < self.min_update_interval: - _LOGGER.debug("Throttling update - %.1f seconds since last update (min: %d)", - (now - self._last_update).total_seconds(), self.min_update_interval) - return - # Store the new power value self._last_power_value = new_power_value @@ -234,7 +226,9 @@ async def _async_update(self, *args): # Update dimmer await self._set_dimmer_percentage(dimmer_percentage, source=DIMMER_MODE_AUTO) - self._last_update = dt_util.utcnow() + timestamp = dt_util.utcnow() + self._last_calculator_run = timestamp + self._last_update = timestamp except Exception as err: _LOGGER.error("Error during controller update: %s", err) @@ -343,7 +337,7 @@ def get_status(self): "shelly_url": self.shelly_url, "shelly_status": self._shelly_status, "update_method": "event_driven", - "min_update_interval": self.min_update_interval, + "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, @@ -476,12 +470,18 @@ async def _async_sync_shelly_dimmer_constraints(self): self._recompute_effective_dimmer_bounds() def _recompute_effective_dimmer_bounds(self): - """Combine user-configured bounds with Shelly hardware limits.""" - new_min = self.min_dimmer_value + """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 + 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) diff --git a/custom_components/boiler_controller/sensor.py b/custom_components/boiler_controller/sensor.py index c0c872e..415dac0 100644 --- a/custom_components/boiler_controller/sensor.py +++ b/custom_components/boiler_controller/sensor.py @@ -132,10 +132,10 @@ def state(self) -> str: @property def extra_state_attributes(self) -> Dict[str, Any]: + # Surface diagnostics/details without overloading the main sensor state attrs: Dict[str, Any] = { "power_sensor": self.controller.power_sensor_id, "shelly_url": self.controller.shelly_url, - "min_update_interval": f"{self.controller.min_update_interval}s", "shelly_poll_interval": f"{self.controller.shelly_poll_interval}s", "integration_version": _integration_version(self.controller, self.config_entry), } @@ -247,6 +247,7 @@ def native_value(self) -> Optional[float]: @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"} @@ -337,8 +338,8 @@ def native_value(self): @property def extra_state_attributes(self) -> Dict[str, Any]: + # Keep transport/update metadata in attributes so the sensor value remains a timestamp attrs = { - "min_update_interval": f"{self.controller.min_update_interval}s", "update_method": "event_driven", "integration_version": _integration_version(self.controller, self.config_entry), } From 457376a6586d6e0d3e4f937ade84423113913e50 Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Mon, 19 Jan 2026 08:11:26 +0100 Subject: [PATCH 04/12] (chore): fix where the device URL failed in for local urls. --- .../boiler_controller/config_flow.py | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/custom_components/boiler_controller/config_flow.py b/custom_components/boiler_controller/config_flow.py index 0c50b4a..28ef292 100644 --- a/custom_components/boiler_controller/config_flow.py +++ b/custom_components/boiler_controller/config_flow.py @@ -1,4 +1,5 @@ import logging +from urllib.parse import urlparse import aiohttp import voluptuous as vol @@ -87,12 +88,42 @@ async def _fetch_shelly_device_id(self, url: str) -> str | None: payload = await client.async_get_device_info() if not payload: _LOGGER.warning("Shelly identity request failed for %s", url) - return None + return self._derive_device_id_from_url(url) device_id = ShellyClient.extract_device_id(payload) - if not device_id: - _LOGGER.warning("Unable to extract Shelly ID from payload: %s", payload) - return device_id + 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 + + @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 + + try: + parsed = urlparse(url) + except ValueError: + return None + + 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): From c05d7ec0cc37accd23a248f0e1135b703ea18aac Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Mon, 19 Jan 2026 16:59:47 +0100 Subject: [PATCH 05/12] chore: more value to our sensors and fix shelly brighness endpoint to use GET --- README.md | 11 ++++++ .../boiler_controller/controller.py | 20 ++++++++--- custom_components/boiler_controller/sensor.py | 25 +++++++++----- .../boiler_controller/shelly_client.py | 34 ++++++++++++------- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 003c632..f0ef51f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,17 @@ For ad-hoc control you also get two helper entities once the integration is set Switching back to `auto` immediately returns control to the P1-driven logic. +## Diagnostics & Telemetry + +The integration exposes multiple diagnostic sensors in Home Assistant. Besides the Shelly telemetry (voltage, current, power, temperature, energy), you will also see **{Integration Name} Last Dimmer Update**. This timestamp sensor records the last moment the controller actually adjusted the Shelly brightness, whether triggered automatically by the calculator or manually via the override entities. It is not tied to general sensor updates, so its value only changes after a dim command is sent to the Shelly. + +| Entity | Type | Description | +| --- | --- | --- | +| **{Integration Name} Status** | Sensor (text) | Shows `Running` when the Shelly dimmer output is ON, `Idle` when it is OFF, and `Error` if Shelly reports an error. Attributes include dimmer bounds, current Shelly metrics, and whether manual mode is active. | +| **{Integration Name} Power Sensor** | Sensor (number, W) | Mirrors the configured P1 entity so you can quickly confirm the source data the controller uses. | +| **{Integration Name} Last Dimmer Update** | Sensor (timestamp) | Timestamp of the last successful Shelly brightness command, regardless of whether it was auto or manual. | +| **{Integration Name} Shelly Brightness / Voltage / Current / Power / Temperature / Energy** | Sensors | Live telemetry polled from the Shelly device. These sensors update whenever the controller’s Shelly poll loop publishes new data. | + ## Logic The default logic: diff --git a/custom_components/boiler_controller/controller.py b/custom_components/boiler_controller/controller.py index 5b9ef55..2362a18 100644 --- a/custom_components/boiler_controller/controller.py +++ b/custom_components/boiler_controller/controller.py @@ -36,7 +36,7 @@ def __init__(self, hass: HomeAssistant, config_entry, integration_version: str | self.integration_version = integration_version self._cancel_listener = None self._poll_task = None - self._last_update = None + self._last_dimmer_update = None self._last_power_value = None self._last_calculator_run = None self._shelly_status = None @@ -228,7 +228,7 @@ async def _async_update(self, *args): timestamp = dt_util.utcnow() self._last_calculator_run = timestamp - self._last_update = timestamp + self._last_dimmer_update = timestamp except Exception as err: _LOGGER.error("Error during controller update: %s", err) @@ -279,6 +279,17 @@ async def _async_poll_shelly(self): _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: + return + self._shelly_status = 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: @@ -331,7 +342,7 @@ def device_info(self): def get_status(self): """Get current controller status.""" return { - "last_update": self._last_update, + "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, @@ -405,7 +416,8 @@ 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_update = dt_util.utcnow() + self._last_dimmer_update = dt_util.utcnow() + await self._async_refresh_shelly_status() async def _ensure_device_identity(self) -> None: """Persist the Shelly device identifier on the config entry when missing.""" diff --git a/custom_components/boiler_controller/sensor.py b/custom_components/boiler_controller/sensor.py index 415dac0..759c2ea 100644 --- a/custom_components/boiler_controller/sensor.py +++ b/custom_components/boiler_controller/sensor.py @@ -70,7 +70,7 @@ async def async_setup_entry( sensors: List[SensorEntity] = [ BoilerControllerStatusSensor(hass, config_entry, controller), PowerSensorStatusSensor(hass, config_entry, controller), - LastUpdateSensor(hass, config_entry, controller), + LastDimmerUpdateSensor(hass, config_entry, controller), ShellyBrightnessSensor(hass, config_entry, controller), ShellyVoltageSensor(hass, config_entry, controller), ShellyCurrentSensor(hass, config_entry, controller), @@ -128,7 +128,12 @@ def _handle_shelly_update(self, status) -> None: @property def state(self) -> str: - return "Active" if self.controller._last_update else "Waiting" + status = self.controller.get_shelly_status() or {} + if status.get("errors"): + return "Error" + if status.get("output"): + return "Running" + return "Idle" @property def extra_state_attributes(self) -> Dict[str, Any]: @@ -149,6 +154,8 @@ def extra_state_attributes(self) -> Dict[str, Any]: "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", } ) @@ -187,8 +194,8 @@ def extra_state_attributes(self) -> Dict[str, Any]: else: attrs["shelly_status"] = "unavailable" - if self.controller._last_update: - attrs["last_update"] = self.controller._last_update.isoformat() + 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 @@ -282,8 +289,8 @@ def device_info(self) -> Dict[str, Any]: return _device_info(self.config_entry, self.controller) -class LastUpdateSensor(SensorEntity): - """Sensor showing when the controller last updated Shelly.""" +class LastDimmerUpdateSensor(SensorEntity): + """Sensor showing when the controller last adjusted the Shelly dimmer.""" _attr_should_poll = False _attr_device_class = SensorDeviceClass.TIMESTAMP @@ -292,8 +299,8 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) - self.hass = hass self.config_entry = config_entry self.controller = controller - self._attr_name = f"{config_entry.title} Last Update" - self._attr_unique_id = f"{config_entry.entry_id}_last_update" + 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] = [] @@ -329,7 +336,7 @@ def _handle_dispatcher_update(self, status) -> None: @property def native_value(self): - value = self.controller._last_update + value = self.controller._last_dimmer_update if isinstance(value, str): parsed = dt_util.parse_datetime(value) if parsed is not None: diff --git a/custom_components/boiler_controller/shelly_client.py b/custom_components/boiler_controller/shelly_client.py index 630293a..51ad2e8 100644 --- a/custom_components/boiler_controller/shelly_client.py +++ b/custom_components/boiler_controller/shelly_client.py @@ -51,7 +51,8 @@ def extract_device_id(payload: Dict[str, Any] | None) -> str | None: def _channel_params(self) -> Dict[str, int]: """Return RPC params targeting the configured light channel.""" - return {"id": self._light_id} + 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.""" @@ -73,13 +74,23 @@ async def async_get_status(self) -> Optional[Dict[str, Any]]: _LOGGER.error("Unexpected Shelly status error: %s", err) return None - async def _async_light_set(self, params: Dict[str, Any]) -> bool: - """Call the Shelly Light.Set RPC method via POST.""" + 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.post( + async with self._session.get( url, - json=params, + params=safe_params, timeout=aiohttp.ClientTimeout(total=10), ) as response: if response.status == 200: @@ -99,16 +110,15 @@ async def _async_light_set(self, params: Dict[str, Any]) -> bool: async def async_set_brightness(self, brightness: int) -> bool: """Set dimmer brightness (0-100).""" clamped = max(0, min(100, int(brightness))) - params = self._channel_params() - params["brightness"] = clamped - params["on"] = bool(clamped) - return await self._async_light_set(params) + 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.""" - params = self._channel_params() - params["on"] = False - return await self._async_light_set(params) + return await self._async_light_set({"on": False}) async def async_test_connection(self) -> bool: """Check whether the Shelly is reachable.""" From bad1a7caecdbad55c635445609cb078cc300435f Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Tue, 20 Jan 2026 21:48:31 +0100 Subject: [PATCH 06/12] (chore): added basic dimmer percentage calculation --- .../boiler_controller/calculator.py | 72 +++++++++++++++---- .../boiler_controller/controller.py | 12 ++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/custom_components/boiler_controller/calculator.py b/custom_components/boiler_controller/calculator.py index e371b3a..93d965d 100644 --- a/custom_components/boiler_controller/calculator.py +++ b/custom_components/boiler_controller/calculator.py @@ -1,8 +1,11 @@ """Logic for translating power readings into Shelly dimmer percentages.""" from __future__ import annotations +import logging from dataclasses import dataclass +_LOGGER = logging.getLogger(__name__) + @dataclass(slots=True) class Calculator: @@ -10,19 +13,64 @@ class Calculator: max_power_watts: float = 3000.0 - def calculate(self, power_value: float, min_dimmer: int, max_dimmer: int) -> int: + # (export_watt, percentage) -- export_watt uses absolute values for readability. + COARSE_THRESHOLDS = [ + (200, 10), + (400, 20), + (600, 30), + (800, 40), + (1000, 50), + (1200, 60), + (1400, 70), + (1600, 80), + (1800, 90), + (2200, 100), + ] + + def calculate( + self, + power_value: float, + min_dimmer: int, + max_dimmer: int, + *, + boiler_consumption: float | None = None, + ) -> int: """Return the dimmer percentage for the given power value.""" - return 0 - # if power_value <= 0: - # return 0 - # # Ensure bounds are valid before interpolating - # min_dimmer = max(0, min(100, int(min_dimmer))) - # max_dimmer = max(min_dimmer, min(100, int(max_dimmer))) + # Calculate total export watts (positive value). + export_watts = max(0.0, -power_value) + + # Only add boiler consumption back when we are already exporting power. + if export_watts > 0 and boiler_consumption: + export_watts += max(0.0, float(boiler_consumption)) + if export_watts == 0: + return 0 + + # Track the upper bound of the coarse segment we'll fall into. + base_percentage = 100 + # Keep the lower watt boundary of the current segment. + lower_limit = 0 + # Keep the lower percentage boundary so we can interpolate. + lower_percentage = 0 + + for limit, percentage in self.COARSE_THRESHOLDS: + if export_watts <= limit: + base_percentage = percentage + break + # Move the lower bound forward until we find the matching segment. + lower_limit = limit + lower_percentage = percentage + + # if we exceed the highest threshold, return max dimmer. + if export_watts > self.COARSE_THRESHOLDS[-1][0]: + return 100 - # if power_value >= self.max_power_watts: - # return max_dimmer + # Width of the current watt interval (avoid divide by zero). + span_watts = max(1, limit - lower_limit) + # Percentage delta covered by this interval. + span_percentage = max(1, base_percentage - lower_percentage) + # How far we are into the interval. + remaining_watts = export_watts - lower_limit - # scale = max(0.0, min(1.0, power_value / self.max_power_watts)) - # percentage = int(min_dimmer + (max_dimmer - min_dimmer) * scale) - # return max(min_dimmer, min(max_dimmer, percentage)) + fine_percentage = lower_percentage + (remaining_watts / span_watts) * span_percentage + return max(min_dimmer, min(max_dimmer, round(fine_percentage))) diff --git a/custom_components/boiler_controller/controller.py b/custom_components/boiler_controller/controller.py index 2362a18..f2ba3fb 100644 --- a/custom_components/boiler_controller/controller.py +++ b/custom_components/boiler_controller/controller.py @@ -220,6 +220,7 @@ async def _async_update(self, *args): power_value, self._effective_min_dimmer_value, self._effective_max_dimmer_value, + boiler_consumption=self._extract_boiler_consumption(), ) _LOGGER.debug("Calculated dimmer percentage: %s%%", dimmer_percentage) @@ -419,6 +420,17 @@ async def _apply_manual_brightness(self): self._last_dimmer_update = dt_util.utcnow() await self._async_refresh_shelly_status() + 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 + 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): From 61c8185067150cd883054f5dddd978ec9887599f Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Tue, 20 Jan 2026 21:56:57 +0100 Subject: [PATCH 07/12] (chore): make sure shelly will be set on when we above the 1% --- custom_components/boiler_controller/controller.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/custom_components/boiler_controller/controller.py b/custom_components/boiler_controller/controller.py index f2ba3fb..0eccf0e 100644 --- a/custom_components/boiler_controller/controller.py +++ b/custom_components/boiler_controller/controller.py @@ -307,25 +307,26 @@ async def _set_dimmer_percentage(self, percentage: int, *, source: str = DIMMER_ _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, - percentage, + desired_percentage, ) else: _LOGGER.info( "Shelly dimmer request (%s): set to %s%% (effective range %s-%s%%)", context, - percentage, + desired_percentage, self._effective_min_dimmer_value, self._effective_max_dimmer_value, ) - success = await self.shelly_client.async_set_brightness(percentage) + success = await self.shelly_client.async_set_brightness(desired_percentage) if success: - _LOGGER.debug("Shelly dimmer set to %s%%", percentage) + _LOGGER.debug("Shelly dimmer set to %s%%", desired_percentage) else: - _LOGGER.warning("Failed to set Shelly dimmer to %s%%", percentage) + _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) From cb4f701f28c1e0ffe005ee76e169b4e860f47ccb Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Wed, 21 Jan 2026 22:47:58 +0100 Subject: [PATCH 08/12] (chore): added calibration method --- .../boiler_controller/__init__.py | 122 ++++++++- custom_components/boiler_controller/button.py | 159 +++++++++++ .../boiler_controller/calculator.py | 33 ++- .../boiler_controller/calibration.py | 81 ++++++ .../boiler_controller/config_flow.py | 6 +- custom_components/boiler_controller/const.py | 14 +- .../boiler_controller/controller.py | 247 +++++++++++++++++- custom_components/boiler_controller/number.py | 39 ++- custom_components/boiler_controller/select.py | 39 ++- custom_components/boiler_controller/sensor.py | 9 + .../boiler_controller/services.yaml | 23 ++ .../boiler_controller/translations/en.json | 6 +- .../boiler_controller/translations/nl.json | 6 +- 13 files changed, 741 insertions(+), 43 deletions(-) create mode 100644 custom_components/boiler_controller/button.py create mode 100644 custom_components/boiler_controller/calibration.py create mode 100644 custom_components/boiler_controller/services.yaml diff --git a/custom_components/boiler_controller/__init__.py b/custom_components/boiler_controller/__init__.py index 8c60409..62ab448 100644 --- a/custom_components/boiler_controller/__init__.py +++ b/custom_components/boiler_controller/__init__.py @@ -1,14 +1,36 @@ import logging -from homeassistant.core import HomeAssistant +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall 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 +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 .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: @@ -32,6 +54,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "controller": controller, } + + await _async_register_services(hass) # Set up platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -57,6 +81,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 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 @@ -65,3 +103,83 @@ 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/button.py b/custom_components/boiler_controller/button.py new file mode 100644 index 0000000..aaa3bd2 --- /dev/null +++ b/custom_components/boiler_controller/button.py @@ -0,0 +1,159 @@ +"""Button entities for the Boiler Controller integration.""" +from __future__ import annotations + +import logging +from typing import Any, Dict + +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.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + CALIBRATION_START_PERCENTAGE, + CALIBRATION_END_PERCENTAGE, + CALIBRATION_STEP_PERCENTAGE, + CALIBRATION_SETTLE_SECONDS, +) + +_LOGGER = logging.getLogger(__name__) + + +def _device_info(config_entry: ConfigEntry, controller) -> Dict[str, Any]: + version = controller.integration_version or str(config_entry.version) + return { + "identifiers": {(DOMAIN, config_entry.entry_id)}, + "name": config_entry.title, + "manufacturer": "Boiler Controller", + "model": "P1 to Shelly Controller", + "sw_version": version, + } + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + controller_data = hass.data[DOMAIN][config_entry.entry_id] + controller = controller_data["controller"] + + async_add_entities( + [ + BoilerCalibrationButton(hass, config_entry, controller), + BoilerCalibrationStopButton(hass, config_entry, controller), + ], + True, + ) + + +class _BaseCalibrationButton(ButtonEntity): + """Shared behavior for calibration buttons.""" + + _attr_should_poll = False + + 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 + + @callback + def _handle_calibration_state(self, *_: Any) -> None: + self.async_write_ha_state() + + @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 93d965d..5215db6 100644 --- a/custom_components/boiler_controller/calculator.py +++ b/custom_components/boiler_controller/calculator.py @@ -12,6 +12,7 @@ class Calculator: """Encapsulate the dimmer percentage calculation logic.""" max_power_watts: float = 3000.0 + thresholds: list[tuple[float, int]] | None = None # (export_watt, percentage) -- export_watt uses absolute values for readability. COARSE_THRESHOLDS = [ @@ -27,6 +28,10 @@ class Calculator: (2200, 100), ] + def __post_init__(self): + if not self.thresholds: + self.thresholds = list(self.COARSE_THRESHOLDS) + def calculate( self, power_value: float, @@ -46,6 +51,8 @@ def calculate( if export_watts == 0: return 0 + thresholds = self.thresholds or self.COARSE_THRESHOLDS + # Track the upper bound of the coarse segment we'll fall into. base_percentage = 100 # Keep the lower watt boundary of the current segment. @@ -53,7 +60,8 @@ def calculate( # Keep the lower percentage boundary so we can interpolate. lower_percentage = 0 - for limit, percentage in self.COARSE_THRESHOLDS: + # Find the coarse segment we fall into. + for limit, percentage in thresholds: if export_watts <= limit: base_percentage = percentage break @@ -61,8 +69,8 @@ def calculate( lower_limit = limit lower_percentage = percentage - # if we exceed the highest threshold, return max dimmer. - if export_watts > self.COARSE_THRESHOLDS[-1][0]: + # if we exceed the highest threshold, return max dimmer. + if export_watts > thresholds[-1][0]: return 100 # Width of the current watt interval (avoid divide by zero). @@ -74,3 +82,22 @@ def calculate( fine_percentage = lower_percentage + (remaining_watts / span_watts) * span_percentage return max(min_dimmer, min(max_dimmer, round(fine_percentage))) + + def set_thresholds(self, thresholds: list[tuple[float, int]] | None) -> None: + """Install a new watt-to-percentage table for future calculations.""" + + if not thresholds: + self.thresholds = list(self.COARSE_THRESHOLDS) + return + + sanitized: list[tuple[float, int]] = [] + for watts, percentage in thresholds: + 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)) + + sanitized.sort(key=lambda item: item[0]) + self.thresholds = sanitized or list(self.COARSE_THRESHOLDS) diff --git a/custom_components/boiler_controller/calibration.py b/custom_components/boiler_controller/calibration.py new file mode 100644 index 0000000..251541a --- /dev/null +++ b/custom_components/boiler_controller/calibration.py @@ -0,0 +1,81 @@ +"""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 28ef292..13b81d5 100644 --- a/custom_components/boiler_controller/config_flow.py +++ b/custom_components/boiler_controller/config_flow.py @@ -318,16 +318,12 @@ async def async_step_init(self, user_input=None): if user_input is not None: if user_input.get("change_devices"): return await self.async_step_power_sensor() - else: - # Only update the advanced settings - return self.async_create_entry(title="", data=user_input) + 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, - vol.Optional("min_dimmer_value", default=self._config_entry.options.get("min_dimmer_value", 0)): int, - vol.Optional("max_dimmer_value", default=self._config_entry.options.get("max_dimmer_value", 100)): int, }) ) diff --git a/custom_components/boiler_controller/const.py b/custom_components/boiler_controller/const.py index 4732286..f8437ba 100644 --- a/custom_components/boiler_controller/const.py +++ b/custom_components/boiler_controller/const.py @@ -1,7 +1,7 @@ DOMAIN = "boiler_controller" VERSION = "0.1.0" -PLATFORMS = ["sensor", "select", "number"] +PLATFORMS = ["sensor", "select", "number", "button"] # Configuration flow step IDs STEP_POWER_SENSOR = "power_sensor" @@ -36,4 +36,14 @@ DEFAULT_CALCULATOR_MIN_INTERVAL = 15 # Seconds between Shelly status polls # Where it updates all the sensor values -DEFAULT_SHELLY_POLL_INTERVAL = 15 \ No newline at end of file +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 \ No newline at end of file diff --git a/custom_components/boiler_controller/controller.py b/custom_components/boiler_controller/controller.py index 0eccf0e..c682950 100644 --- a/custom_components/boiler_controller/controller.py +++ b/custom_components/boiler_controller/controller.py @@ -23,6 +23,7 @@ ) from .shelly_client import ShellyClient from .calculator import Calculator +from .calibration import CalibrationStore, points_to_thresholds _LOGGER = logging.getLogger(__name__) @@ -36,6 +37,7 @@ def __init__(self, hass: HomeAssistant, config_entry, integration_version: str | self.integration_version = integration_version 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 @@ -43,6 +45,7 @@ def __init__(self, hass: HomeAssistant, config_entry, integration_version: str | 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" # Configuration self.shelly_url = config_entry.data[CONF_SHELLY_URL] @@ -53,10 +56,17 @@ def __init__(self, hass: HomeAssistant, config_entry, integration_version: str | 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(0, 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 = config_entry.options.get("min_dimmer_value", DEFAULT_MIN_DIMMER_VALUE) - self.max_dimmer_value = config_entry.options.get("max_dimmer_value", DEFAULT_MAX_DIMMER_VALUE) + 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 @@ -91,6 +101,8 @@ async def async_start(self): else: _LOGGER.warning("Unable to reach Shelly device at %s during startup", self.shelly_url) + await self._async_load_calibration_profile() + # Start listening to power sensor state changes self._cancel_listener = async_track_state_change_event( self.hass, @@ -112,6 +124,10 @@ async def async_start(self): @callback async def _async_power_sensor_changed(self, event: Event): """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() @@ -200,6 +216,10 @@ async def _validate_configuration(self) -> bool: 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 + try: # Get current power consumption/production from P1 power_value = await self._get_p1_power_value() @@ -268,10 +288,11 @@ async def _async_poll_shelly(self): """Poll Shelly status at the configured interval.""" while True: try: - status = await self.shelly_client.async_get_status() - if status is not None: - self._shelly_status = status - async_dispatcher_send(self.hass, self._dispatcher_signal, status) + if not self._polling_suspended: + status = await self.shelly_client.async_get_status() + if status is not None: + self._shelly_status = 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") @@ -294,7 +315,12 @@ async def _async_refresh_shelly_status(self): async def _set_dimmer_percentage(self, percentage: int, *, source: str = DIMMER_MODE_AUTO): """Set the dimmer to the specified percentage using Shelly API.""" try: - context = "manual override" if source == DIMMER_MODE_MANUAL else "auto calculation" + 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) @@ -343,6 +369,7 @@ def device_info(self): 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, @@ -360,12 +387,19 @@ def get_status(self): "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 + def get_calibration_profile(self): + """Return the active calibration profile, if any.""" + return self._calibration_profile + def get_shelly_status_signal(self): """Return dispatcher signal name for Shelly status updates.""" return self._dispatcher_signal @@ -378,6 +412,10 @@ def get_manual_brightness_signal(self): """Dispatcher signal for manual brightness changes.""" return self._manual_brightness_signal + def get_calibration_state_signal(self): + """Dispatcher signal fired when calibration state changes.""" + return self._calibration_signal + @property def dimming_mode(self) -> str: return self._dimming_mode @@ -386,6 +424,24 @@ def dimming_mode(self) -> str: def manual_brightness(self) -> int: return self._manual_brightness + @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 mode not in (DIMMER_MODE_AUTO, DIMMER_MODE_MANUAL): @@ -404,6 +460,8 @@ async def async_set_dimming_mode(self, mode: str): 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(0, min(100, int(brightness))) if brightness == self._manual_brightness: return @@ -421,6 +479,92 @@ async def _apply_manual_brightness(self): 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 {} @@ -432,6 +576,21 @@ def _extract_boiler_consumption(self) -> float: except (TypeError, ValueError): return 0.0 + 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 + + self._shelly_status = status + async_dispatcher_send(self.hass, self._dispatcher_signal, status) + + 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): @@ -552,6 +711,80 @@ def _search(node): 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: + 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.""" + + if self._polling_suspended: + return + + self._polling_suspended = True + _LOGGER.debug("Shelly polling suspended for calibration") + + def _resume_polling(self) -> None: + """Resume the Shelly polling loop after calibration completes.""" + + if not self._polling_suspended: + return + + self._polling_suspended = False + _LOGGER.debug("Shelly polling resumed after calibration") + + 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) + + 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 + + self._apply_calibration_profile(profile) + if profile: + _LOGGER.info( + "Loaded calibration profile with %s points", + len(profile.get("points", [])), + ) + + def _apply_calibration_profile(self, profile: dict | None) -> None: + """Install the provided calibration profile or fall back to defaults.""" + + self._calibration_profile = profile + points = profile.get("points", []) if profile else [] + thresholds = points_to_thresholds(points) + self._calculator.set_thresholds(thresholds if thresholds else None) + @staticmethod def _get_state_unit(state) -> str: """Fetch unit from state attributes, falling back to native unit.""" diff --git a/custom_components/boiler_controller/number.py b/custom_components/boiler_controller/number.py index b42615c..3e2214f 100644 --- a/custom_components/boiler_controller/number.py +++ b/custom_components/boiler_controller/number.py @@ -1,9 +1,12 @@ """Number entities for controlling manual brightness.""" from __future__ import annotations +from typing import Callable, List + 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 @@ -37,19 +40,29 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, 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._remove_dispatcher = None + self._remove_callbacks: List[Callable[[], None]] = [] async def async_added_to_hass(self) -> None: - self._remove_dispatcher = async_dispatcher_connect( - self.hass, - self.controller.get_manual_brightness_signal(), - self._handle_manual_brightness_update, + self._remove_callbacks.append( + async_dispatcher_connect( + self.hass, + self.controller.get_manual_brightness_signal(), + self._handle_manual_brightness_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: - if self._remove_dispatcher: - self._remove_dispatcher() - self._remove_dispatcher = None + for remove in self._remove_callbacks: + remove() + self._remove_callbacks.clear() @callback def _handle_manual_brightness_update(self, value: int) -> None: @@ -57,8 +70,18 @@ def _handle_manual_brightness_update(self, value: int) -> None: 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: + self.async_write_ha_state() + + @property + def available(self) -> bool: + return super().available and not self.controller.is_calibration_active + @property def device_info(self): return { diff --git a/custom_components/boiler_controller/select.py b/custom_components/boiler_controller/select.py index ed300a1..fe5c7db 100644 --- a/custom_components/boiler_controller/select.py +++ b/custom_components/boiler_controller/select.py @@ -1,9 +1,12 @@ """Select entities for the Boiler Controller integration.""" from __future__ import annotations +from typing import List, Callable + 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 @@ -34,28 +37,48 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, 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._remove_dispatcher = None + self._remove_callbacks: List[Callable[[], None]] = [] async def async_added_to_hass(self) -> None: - self._remove_dispatcher = async_dispatcher_connect( - self.hass, - self.controller.get_dimming_mode_signal(), - self._handle_mode_update, + self._remove_callbacks.append( + async_dispatcher_connect( + self.hass, + self.controller.get_dimming_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: - if self._remove_dispatcher: - self._remove_dispatcher() - self._remove_dispatcher = None + for remove in self._remove_callbacks: + remove() + self._remove_callbacks.clear() @callback 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 + @property def device_info(self): return { diff --git a/custom_components/boiler_controller/sensor.py b/custom_components/boiler_controller/sensor.py index 759c2ea..31f9bd7 100644 --- a/custom_components/boiler_controller/sensor.py +++ b/custom_components/boiler_controller/sensor.py @@ -11,6 +11,7 @@ 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 try: @@ -128,6 +129,8 @@ def _handle_shelly_update(self, status) -> None: @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" @@ -156,6 +159,9 @@ def extra_state_attributes(self) -> Dict[str, Any]: "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"), } ) @@ -212,6 +218,7 @@ class PowerSensorStatusSensor(SensorEntity): _attr_should_poll = False _attr_device_class = SensorDeviceClass.POWER _attr_native_unit_of_measurement = "W" + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller) -> None: self.hass = hass @@ -294,6 +301,7 @@ class LastDimmerUpdateSensor(SensorEntity): _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 @@ -363,6 +371,7 @@ class ShellySensorBase(SensorEntity): """Base class for Shelly telemetry sensors fed by the controller polling loop.""" _attr_should_poll = False + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, diff --git a/custom_components/boiler_controller/services.yaml b/custom_components/boiler_controller/services.yaml new file mode 100644 index 0000000..7bd5eb9 --- /dev/null +++ b/custom_components/boiler_controller/services.yaml @@ -0,0 +1,23 @@ +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/translations/en.json b/custom_components/boiler_controller/translations/en.json index 28a86d0..4c64189 100644 --- a/custom_components/boiler_controller/translations/en.json +++ b/custom_components/boiler_controller/translations/en.json @@ -41,11 +41,9 @@ "step": { "init": { "title": "Boiler Controller Options", - "description": "Configure advanced settings for the boiler controller", + "description": "Switch the power sensor or Shelly device used by this controller", "data": { - "change_devices": "Change power sensor or Shelly settings", - "min_dimmer_value": "Minimum Dimmer Value (%)", - "max_dimmer_value": "Maximum Dimmer Value (%)" + "change_devices": "Change power sensor or Shelly settings" } }, "power_sensor": { diff --git a/custom_components/boiler_controller/translations/nl.json b/custom_components/boiler_controller/translations/nl.json index 4f8ef05..003bc96 100644 --- a/custom_components/boiler_controller/translations/nl.json +++ b/custom_components/boiler_controller/translations/nl.json @@ -41,11 +41,9 @@ "step": { "init": { "title": "Boiler Controller Opties", - "description": "Configureer geavanceerde instellingen voor de boiler controller", + "description": "Wijzig de vermogenssensor of Shelly die deze controller gebruikt", "data": { - "change_devices": "Wijzig vermogenssensor of Shelly instellingen", - "min_dimmer_value": "Minimale Dimmer Waarde (%)", - "max_dimmer_value": "Maximale Dimmer Waarde (%)" + "change_devices": "Wijzig vermogenssensor of Shelly instellingen" } }, "power_sensor": { From 7cb19b0b20db4e7e7f44dc989b84c9f6c12ee38d Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Thu, 22 Jan 2026 17:03:24 +0100 Subject: [PATCH 09/12] chore: added max export watt as safety limit --- .../boiler_controller/calculator.py | 122 ++++++++++++++++-- custom_components/boiler_controller/const.py | 5 +- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/custom_components/boiler_controller/calculator.py b/custom_components/boiler_controller/calculator.py index 5215db6..f69e75c 100644 --- a/custom_components/boiler_controller/calculator.py +++ b/custom_components/boiler_controller/calculator.py @@ -2,7 +2,9 @@ from __future__ import annotations import logging -from dataclasses import dataclass +from dataclasses import dataclass, field + +from .const import MAX_EXPORT_WATTS _LOGGER = logging.getLogger(__name__) @@ -12,7 +14,13 @@ class Calculator: """Encapsulate the dimmer percentage calculation logic.""" max_power_watts: float = 3000.0 + # List of (watt_threshold, dimmer_percentage) tuples. thresholds: list[tuple[float, int]] | None = None + # holds the source of the currently used thresholds + # "calibration" if from a calibration profile, "default" if we are using the default curve. + _threshold_source: str = field(init=False, default="default") + # Debug trace of the last calculation performed. + _last_trace: dict[str, float | int | str] | None = field(init=False, default=None) # (export_watt, percentage) -- export_watt uses absolute values for readability. COARSE_THRESHOLDS = [ @@ -31,6 +39,10 @@ class Calculator: def __post_init__(self): if not self.thresholds: self.thresholds = list(self.COARSE_THRESHOLDS) + self._threshold_source = "default" + else: + self._threshold_source = "calibration" + self._last_trace = None def calculate( self, @@ -42,15 +54,41 @@ def calculate( ) -> int: """Return the dimmer percentage for the given power value.""" - # Calculate total export watts (positive value). - export_watts = max(0.0, -power_value) - - # Only add boiler consumption back when we are already exporting power. - if export_watts > 0 and boiler_consumption: - export_watts += max(0.0, float(boiler_consumption)) + boiler_watts = max(0.0, float(boiler_consumption)) if boiler_consumption else 0.0 + grid_flow_watts = float(power_value) + + # Reconstruct the pre-boiler export by adding Shelly usage back in. + export_watts = max(0.0, -grid_flow_watts + boiler_watts) + + # Set the clipping flag if we exceed the hard limit. + # e.g. we don't want to push more than 2.2kW into the boiler. + # as that could trip breakers or damage equipment. + clipped = False + if export_watts > MAX_EXPORT_WATTS: + _LOGGER.debug( + "Export %.1f W exceeds hard limit %.1f W; capping to %.1f W", + export_watts, + MAX_EXPORT_WATTS, + MAX_EXPORT_WATTS, + ) + export_watts = MAX_EXPORT_WATTS + clipped = True if export_watts == 0: + self._last_trace = { + "source": "calibration profile" if self._threshold_source == "calibration" else "default curve", + "grid_flow_watts": grid_flow_watts, + "boiler_watts": boiler_watts, + "note": "no_surplus", + } + _LOGGER.debug( + "Insufficient surplus (grid %.1f W import, boiler %.1f W); keeping dimmer at 0%%", + grid_flow_watts, + boiler_watts, + ) return 0 + # Determine which set of thresholds to use. + source_label = "calibration profile" if self._threshold_source == "calibration" else "default curve" thresholds = self.thresholds or self.COARSE_THRESHOLDS # Track the upper bound of the coarse segment we'll fall into. @@ -59,18 +97,42 @@ def calculate( lower_limit = 0 # Keep the lower percentage boundary so we can interpolate. lower_percentage = 0 + # The index of the matching threshold. We use this for logging only. + match_index = len(thresholds) - 1 + # The index of the lower point in the segment. we use this for logging only. + lower_index = 0 # Find the coarse segment we fall into. - for limit, percentage in thresholds: + for idx, (limit, percentage) in enumerate(thresholds): if export_watts <= limit: base_percentage = percentage + match_index = idx break # Move the lower bound forward until we find the matching segment. lower_limit = limit lower_percentage = percentage + lower_index = idx # if we exceed the highest threshold, return max dimmer. if export_watts > thresholds[-1][0]: + upper_limit, upper_percentage = thresholds[-1] + self._last_trace = { + "source": source_label, + "export_watts": export_watts, + "segment_upper_watts": upper_limit, + "segment_upper_percentage": upper_percentage, + "note": "hard_cap" if clipped else "above_max", + "grid_flow_watts": grid_flow_watts, + "boiler_watts": boiler_watts, + } + _LOGGER.debug( + "Export %.1f W exceeds %s point #%d (%.1f W -> %d%%); forcing 100%%", + export_watts, + source_label, + len(thresholds), + upper_limit, + upper_percentage, + ) return 100 # Width of the current watt interval (avoid divide by zero). @@ -79,8 +141,40 @@ def calculate( span_percentage = max(1, base_percentage - lower_percentage) # How far we are into the interval. remaining_watts = export_watts - lower_limit - + # Interpolate the fine-grained percentage within the segment. fine_percentage = lower_percentage + (remaining_watts / span_watts) * span_percentage + + # Calculate the index of the lower point in the segment for logging. + lower_point_index = lower_index + 1 if lower_limit or lower_percentage else 0 + self._last_trace = { + "source": source_label, + "export_watts": export_watts, + "segment_lower_watts": lower_limit, + "segment_lower_percentage": lower_percentage, + "segment_upper_watts": limit, + "segment_upper_percentage": base_percentage, + "segment_index": match_index + 1, + "lower_point_index": lower_point_index, + "grid_flow_watts": grid_flow_watts, + "boiler_watts": boiler_watts, + "note": "hard_cap" if clipped else "segment", + } + _LOGGER.debug( + "Export %.1f W (grid %.1f W, boiler %.1f W)%s matched %s point #%d (%.1f W -> %d%%); interpolated %.1f%% between lower %.1f W (%d%%) and upper %.1f W (%d%%)", + export_watts, + grid_flow_watts, + boiler_watts, + " [capped]" if clipped else "", + source_label, + match_index + 1, + limit, + base_percentage, + fine_percentage, + lower_limit, + lower_percentage, + limit, + base_percentage, + ) return max(min_dimmer, min(max_dimmer, round(fine_percentage))) def set_thresholds(self, thresholds: list[tuple[float, int]] | None) -> None: @@ -88,6 +182,8 @@ def set_thresholds(self, thresholds: list[tuple[float, int]] | None) -> None: if not thresholds: self.thresholds = list(self.COARSE_THRESHOLDS) + self._threshold_source = "default" + self._last_trace = None return sanitized: list[tuple[float, int]] = [] @@ -100,4 +196,10 @@ def set_thresholds(self, thresholds: list[tuple[float, int]] | None) -> None: sanitized.append((clean_watts, clean_percentage)) sanitized.sort(key=lambda item: item[0]) - self.thresholds = sanitized or list(self.COARSE_THRESHOLDS) + if sanitized: + self.thresholds = sanitized + self._threshold_source = "calibration" + else: + self.thresholds = list(self.COARSE_THRESHOLDS) + self._threshold_source = "default" + self._last_trace = None diff --git a/custom_components/boiler_controller/const.py b/custom_components/boiler_controller/const.py index f8437ba..fb74c26 100644 --- a/custom_components/boiler_controller/const.py +++ b/custom_components/boiler_controller/const.py @@ -46,4 +46,7 @@ CALIBRATION_END_PERCENTAGE = 100 CALIBRATION_STEP_PERCENTAGE = 1 CALIBRATION_SETTLE_SECONDS = 3 -CALIBRATION_STORAGE_VERSION = 1 \ No newline at end of file +CALIBRATION_STORAGE_VERSION = 1 + +# Safety limits +MAX_EXPORT_WATTS = 2200 \ No newline at end of file From 0837ec4903a8f0696eb1ace775996ceab1db6d1b Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Wed, 28 Jan 2026 21:57:44 +0100 Subject: [PATCH 10/12] (chore): use a cleaner calculation method --- .../boiler_controller/calculator.py | 359 ++++++++++++------ .../boiler_controller/controller.py | 33 +- 2 files changed, 273 insertions(+), 119 deletions(-) diff --git a/custom_components/boiler_controller/calculator.py b/custom_components/boiler_controller/calculator.py index f69e75c..c55f778 100644 --- a/custom_components/boiler_controller/calculator.py +++ b/custom_components/boiler_controller/calculator.py @@ -8,41 +8,118 @@ _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 + +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], +) @dataclass(slots=True) class Calculator: """Encapsulate the dimmer percentage calculation logic.""" - max_power_watts: float = 3000.0 # List of (watt_threshold, dimmer_percentage) tuples. - thresholds: list[tuple[float, int]] | None = None - # holds the source of the currently used thresholds + 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. - _threshold_source: str = field(init=False, default="default") + _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) - # (export_watt, percentage) -- export_watt uses absolute values for readability. - COARSE_THRESHOLDS = [ - (200, 10), - (400, 20), - (600, 30), - (800, 40), - (1000, 50), - (1200, 60), - (1400, 70), - (1600, 80), - (1800, 90), - (2200, 100), - ] - def __post_init__(self): - if not self.thresholds: - self.thresholds = list(self.COARSE_THRESHOLDS) - self._threshold_source = "default" - else: - self._threshold_source = "calibration" - self._last_trace = None + initial_profile = list(self.calibration_profile) if self.calibration_profile else None + self.set_calibration_profile(initial_profile) def calculate( self, @@ -51,155 +128,205 @@ def calculate( max_dimmer: int, *, boiler_consumption: float | None = None, + current_dimmer: int | None = None, ) -> 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 - # Reconstruct the pre-boiler export by adding Shelly usage back in. - export_watts = max(0.0, -grid_flow_watts + 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) - # Set the clipping flag if we exceed the hard limit. - # e.g. we don't want to push more than 2.2kW into the boiler. - # as that could trip breakers or damage equipment. clipped = False - if export_watts > MAX_EXPORT_WATTS: - _LOGGER.debug( - "Export %.1f W exceeds hard limit %.1f W; capping to %.1f W", - export_watts, + 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, ) - export_watts = MAX_EXPORT_WATTS + target_watts = MAX_EXPORT_WATTS clipped = True - if export_watts == 0: + + if target_watts <= 0: self._last_trace = { - "source": "calibration profile" if self._threshold_source == "calibration" else "default curve", + "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.debug( - "Insufficient surplus (grid %.1f W import, boiler %.1f W); keeping dimmer at 0%%", + _LOGGER.info( + "Insufficient surplus (grid %.1f W, boiler %.1f W); keeping dimmer at 0%%", grid_flow_watts, boiler_watts, ) return 0 - # Determine which set of thresholds to use. - source_label = "calibration profile" if self._threshold_source == "calibration" else "default curve" - thresholds = self.thresholds or self.COARSE_THRESHOLDS - - # Track the upper bound of the coarse segment we'll fall into. - base_percentage = 100 - # Keep the lower watt boundary of the current segment. - lower_limit = 0 - # Keep the lower percentage boundary so we can interpolate. - lower_percentage = 0 - # The index of the matching threshold. We use this for logging only. + 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 - # The index of the lower point in the segment. we use this for logging only. - lower_index = 0 - # Find the coarse segment we fall into. - for idx, (limit, percentage) in enumerate(thresholds): - if export_watts <= limit: - base_percentage = percentage + 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 - # Move the lower bound forward until we find the matching segment. lower_limit = limit lower_percentage = percentage - lower_index = idx + 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)) - # if we exceed the highest threshold, return max dimmer. - if export_watts > thresholds[-1][0]: - upper_limit, upper_percentage = thresholds[-1] - self._last_trace = { - "source": source_label, - "export_watts": export_watts, - "segment_upper_watts": upper_limit, - "segment_upper_percentage": upper_percentage, - "note": "hard_cap" if clipped else "above_max", - "grid_flow_watts": grid_flow_watts, - "boiler_watts": boiler_watts, - } - _LOGGER.debug( - "Export %.1f W exceeds %s point #%d (%.1f W -> %d%%); forcing 100%%", - export_watts, - source_label, - len(thresholds), - upper_limit, - upper_percentage, - ) - return 100 - - # Width of the current watt interval (avoid divide by zero). - span_watts = max(1, limit - lower_limit) - # Percentage delta covered by this interval. - span_percentage = max(1, base_percentage - lower_percentage) - # How far we are into the interval. - remaining_watts = export_watts - lower_limit - # Interpolate the fine-grained percentage within the segment. - fine_percentage = lower_percentage + (remaining_watts / span_watts) * span_percentage - - # Calculate the index of the lower point in the segment for logging. - lower_point_index = lower_index + 1 if lower_limit or lower_percentage else 0 self._last_trace = { "source": source_label, - "export_watts": export_watts, + "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": limit, - "segment_upper_percentage": base_percentage, + "segment_upper_watts": upper_limit, + "segment_upper_percentage": upper_percentage, "segment_index": match_index + 1, - "lower_point_index": lower_point_index, - "grid_flow_watts": grid_flow_watts, - "boiler_watts": boiler_watts, + "selected_percentage": selected_percentage, "note": "hard_cap" if clipped else "segment", } - _LOGGER.debug( - "Export %.1f W (grid %.1f W, boiler %.1f W)%s matched %s point #%d (%.1f W -> %d%%); interpolated %.1f%% between lower %.1f W (%d%%) and upper %.1f W (%d%%)", - export_watts, + _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, - limit, - base_percentage, - fine_percentage, lower_limit, lower_percentage, - limit, - base_percentage, + upper_limit, + upper_percentage, ) - return max(min_dimmer, min(max_dimmer, round(fine_percentage))) + return final_percentage - def set_thresholds(self, thresholds: list[tuple[float, int]] | None) -> None: + def set_calibration_profile(self, profile: list[tuple[float, int]] | None) -> None: """Install a new watt-to-percentage table for future calculations.""" - if not thresholds: - self.thresholds = list(self.COARSE_THRESHOLDS) - self._threshold_source = "default" + 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]] = [] - for watts, percentage in thresholds: + 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 sanitized: - self.thresholds = sanitized - self._threshold_source = "calibration" - else: - self.thresholds = list(self.COARSE_THRESHOLDS) - self._threshold_source = "default" + 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.""" + + if value is None: + return 0 + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 0 diff --git a/custom_components/boiler_controller/controller.py b/custom_components/boiler_controller/controller.py index c682950..e1b479f 100644 --- a/custom_components/boiler_controller/controller.py +++ b/custom_components/boiler_controller/controller.py @@ -42,6 +42,7 @@ def __init__(self, hass: HomeAssistant, config_entry, integration_version: str | 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" @@ -241,9 +242,9 @@ async def _async_update(self, *args): self._effective_min_dimmer_value, self._effective_max_dimmer_value, boiler_consumption=self._extract_boiler_consumption(), + current_dimmer=self._extract_boiler_brightness(), ) - _LOGGER.debug("Calculated dimmer percentage: %s%%", dimmer_percentage) - + # Update dimmer await self._set_dimmer_percentage(dimmer_percentage, source=DIMMER_MODE_AUTO) @@ -292,6 +293,7 @@ async def _async_poll_shelly(self): 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: @@ -308,6 +310,7 @@ async def _async_refresh_shelly_status(self): if status is None: 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) @@ -327,6 +330,7 @@ async def _set_dimmer_percentage(self, percentage: int, *, source: str = DIMMER_ 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: @@ -349,6 +353,7 @@ async def _set_dimmer_percentage(self, percentage: int, *, source: str = DIMMER_ 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: @@ -576,6 +581,27 @@ def _extract_boiler_consumption(self) -> float: 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): + 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 + 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() @@ -583,6 +609,7 @@ async def _async_measure_boiler_power(self) -> float: return 0.0 self._shelly_status = status + self._update_cached_brightness(status) async_dispatcher_send(self.hass, self._dispatcher_signal, status) value = status.get("apower", status.get("power")) @@ -783,7 +810,7 @@ def _apply_calibration_profile(self, profile: dict | None) -> None: self._calibration_profile = profile points = profile.get("points", []) if profile else [] thresholds = points_to_thresholds(points) - self._calculator.set_thresholds(thresholds if thresholds else None) + self._calculator.set_calibration_profile(thresholds if thresholds else None) @staticmethod def _get_state_unit(state) -> str: From 11cdf4a920b5f1173d1a28079c6a4fc64529341c Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Thu, 29 Jan 2026 16:29:28 +0100 Subject: [PATCH 11/12] chore: update readme --- README.md | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f0ef51f..f52dbe4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Boiler Controller HA Integration -A Home Assistant integration for automatically controlling a Shelly Dimmer 0/1-10V PM Gen3 based on P1 smart meter data. +Boiler Controller turns your electric boiler into a water battery. Instead of exporting surplus energy, it dumps the excess reported by your P1 meter straight into the heater so you store free solar/dynamic energy as hot water. ## Features @@ -26,16 +26,27 @@ This integration: The integration requires: - A working P1 smart meter integration in Home Assistant -- A Shelly Dimmer 0/1-10V PM Gen3 device connected to Home Assistant +- A Boiler Controller device configured with the P1 power sensor and Shelly Dimmer -## Advanced Settings & Manual Override +### Calibration (do this before use) + +Every dimmer behaves slightly differently and the Shelly power stage reacts differently as it warms up. Run the calibration sweep once before you start relying on the automation so the controller knows how many watts belong to each brightness step. + +1. Open the Boiler Controller device page in Home Assistant and press the calibration button (or, if you prefer Services, call `boiler_controller.run_calibration` for your config entry). +2. Let the sweep run from 20% (*) to 100%. The controller will record the wattage for every 1% step and store it as the active profile. +3. If you change hardware or notice large seasonal deviations, rerun the calibration—this profile is the backbone of the calculator (*). + +If no calibration exists, the integration falls back to the built-in profile listed in `calculator.py`, but the results are always better with a fresh measurement from your own installation. -Via the integration options you can adjust the minimum and maximum dimmer bounds that the automatic logic uses. +\* The power regulator behaves erratically below 20%. +\* A future release will redo the calibration automatically based on detected performance drift. + +## Advanced Settings & Manual Override For ad-hoc control you also get two helper entities once the integration is set up: - `Select` – **{Integration Name} Dimmer Mode**: choose `auto` to let the controller react to power usage, or `manual` to override the Shelly brightness yourself. -- `Number` – **{Integration Name} Manual Brightness**: specify the brightness percentage (0–100). This value is only applied when the mode select is in `manual`. +- `Number` – **{Integration Name} Manual Brightness**: specify the brightness percentage (20–100). This value is only applied when the mode select is in `manual`. Switching back to `auto` immediately returns control to the P1-driven logic. @@ -52,9 +63,11 @@ The integration exposes multiple diagnostic sensors in Home Assistant. Besides t ## Logic -The default logic: -- At 0W consumption: dimmer at minimum -- At 3000W+ consumption: dimmer at maximum -- In between: linearly scaled between min and max +The controller always works from the calibration profile (either your recorded one or the bundled default curve): + +1. **Baseline lookup** – take the current Shelly brightness and read the expected wattage from the profile. If that entry is missing (e.g. Shelly just woke up), fall back to the live Shelly reading. +2. **Add the grid delta** – combine the baseline with the current P1 surplus/deficit (negative grid flow means import). This becomes the target wattage we would like the boiler to draw. +3. **Find the best matching point** – search the calibration profile for the lowest percentage whose wattage can deliver the target value (respecting the hard cap of 2.2 kW, `MAX_EXPORT_WATTS`). +4. **Clamp to allowed range** – enforce the configured min/max dimmer bounds and send that final percentage to the Shelly. -This logic can be customized in the `controller.py` file. \ No newline at end of file +Because the profile already captures how your dimmer responds at each step, this approach automatically compensates for situations where warm hardware performs better than cold hardware. From f172a8cb7cf9e1f57108a2a091e105fd1fb9b328 Mon Sep 17 00:00:00 2001 From: Rein de Vries Date: Thu, 29 Jan 2026 16:29:46 +0100 Subject: [PATCH 12/12] chore: added min percentage for manual mode --- custom_components/boiler_controller/calculator.py | 2 ++ custom_components/boiler_controller/const.py | 3 ++- custom_components/boiler_controller/controller.py | 5 +++-- custom_components/boiler_controller/number.py | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/custom_components/boiler_controller/calculator.py b/custom_components/boiler_controller/calculator.py index c55f778..a73214d 100644 --- a/custom_components/boiler_controller/calculator.py +++ b/custom_components/boiler_controller/calculator.py @@ -103,6 +103,8 @@ key=lambda item: item[0], ) +# TODO - we should automate changes of this profile over time based on observed performance drift. + @dataclass(slots=True) class Calculator: """Encapsulate the dimmer percentage calculation logic.""" diff --git a/custom_components/boiler_controller/const.py b/custom_components/boiler_controller/const.py index fb74c26..8d7b85a 100644 --- a/custom_components/boiler_controller/const.py +++ b/custom_components/boiler_controller/const.py @@ -28,7 +28,8 @@ DEFAULT_MIN_DIMMER_VALUE = 0 DEFAULT_MAX_DIMMER_VALUE = 100 # Manual override defaults and modes -DEFAULT_MANUAL_BRIGHTNESS = 0 +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] diff --git a/custom_components/boiler_controller/controller.py b/custom_components/boiler_controller/controller.py index e1b479f..c8d25b8 100644 --- a/custom_components/boiler_controller/controller.py +++ b/custom_components/boiler_controller/controller.py @@ -17,6 +17,7 @@ DEFAULT_CALCULATOR_MIN_INTERVAL, DEFAULT_SHELLY_POLL_INTERVAL, DEFAULT_MANUAL_BRIGHTNESS, + MIN_MANUAL_BRIGHTNESS, DIMMER_MODE_AUTO, DIMMER_MODE_MANUAL, DIMMER_MODES, @@ -56,7 +57,7 @@ def __init__(self, hass: HomeAssistant, config_entry, integration_version: str | 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(0, min(100, int(stored_manual))) + 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() @@ -467,7 +468,7 @@ 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(0, min(100, int(brightness))) + brightness = max(MIN_MANUAL_BRIGHTNESS, min(100, int(brightness))) if brightness == self._manual_brightness: return self._manual_brightness = brightness diff --git a/custom_components/boiler_controller/number.py b/custom_components/boiler_controller/number.py index 3e2214f..71906e6 100644 --- a/custom_components/boiler_controller/number.py +++ b/custom_components/boiler_controller/number.py @@ -10,7 +10,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, MIN_MANUAL_BRIGHTNESS async def async_setup_entry( @@ -27,7 +27,7 @@ class BoilerControllerManualBrightnessNumber(NumberEntity): """Number entity exposing manual brightness override.""" _attr_should_poll = False - _attr_native_min_value = 0 + _attr_native_min_value = MIN_MANUAL_BRIGHTNESS _attr_native_max_value = 100 _attr_native_step = 1 _attr_mode = NumberMode.BOX