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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/hassfest.yaml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -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"
74 changes: 72 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,73 @@
# Boiler Controller HA integration
# Boiler Controller HA Integration

[TODO]
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

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 Boiler Controller device configured with the P1 power sensor and Shelly Dimmer

### 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.

\* 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 (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.

## 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 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.

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.
Binary file added custom_components/.DS_Store
Binary file not shown.
185 changes: 185 additions & 0 deletions custom_components/boiler_controller/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import logging

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,
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:
"""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,
}

await _async_register_services(hass)

# 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)

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


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")
Loading
Loading