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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.DS_Store

169 changes: 18 additions & 151 deletions custom_components/boiler_controller/__init__.py
Original file line number Diff line number Diff line change
@@ -1,194 +1,61 @@
import logging
"""Boiler Controller Home Assistant integration."""
from __future__ import annotations

import voluptuous as vol
import logging

from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.loader import async_get_integration

from .const import (
DOMAIN,
PLATFORMS,
SERVICE_RUN_CALIBRATION,
SERVICE_CANCEL_CALIBRATION,
ATTR_CONFIG_ENTRY_ID,
CALIBRATION_START_PERCENTAGE,
CALIBRATION_END_PERCENTAGE,
CALIBRATION_STEP_PERCENTAGE,
CALIBRATION_SETTLE_SECONDS,
)
from .const import DOMAIN, PLATFORMS, VERSION
from .controller import BoilerController

_LOGGER = logging.getLogger(__name__)

ENTRY_ID_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_CONFIG_ENTRY_ID): cv.string,
}
)
RUN_CALIBRATION_SCHEMA = ENTRY_ID_SCHEMA
CANCEL_CALIBRATION_SCHEMA = ENTRY_ID_SCHEMA


# Set up the component
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Boiler Controller from a config entry."""
_LOGGER.info("Setting up Boiler Controller")

from .const import VERSION


try:
integration = await async_get_integration(hass, DOMAIN)
integration_version = integration.version
integration_version = str(integration.version)
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Could not get integration version: %s, using fallback", err)
integration_version = None

# Ensure we always have a valid version string
integration_version = str(integration_version) if integration_version else VERSION
_LOGGER.warning("Could not get integration version: %s", err)
integration_version = VERSION

# Create the controller
controller = BoilerController(hass, entry, integration_version)

# Start the controller (now handles missing entities gracefully)
success = await controller.async_start()
if not success:
_LOGGER.error("Failed to start Boiler Controller")
# Don't raise ConfigEntryNotReady anymore - let it start and wait for entities
_LOGGER.warning("Boiler Controller will continue running and wait for entities to become available")

# Store the controller
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"controller": controller,
}

await _async_register_services(hass)

# Set up platforms
_LOGGER.warning("Boiler Controller started with warnings – will retry")

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"controller": controller}

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

_LOGGER.info("Boiler Controller setup completed")
return True

# Implement unloading and reloading of the config entry

async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
_LOGGER.info("Unloading Boiler Controller")

# Unload platforms

unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

# Stop the controller

controller_data = hass.data.get(DOMAIN, {}).get(entry.entry_id)
if controller_data:
controller = controller_data.get("controller")
if controller:
await controller.async_stop()

# Remove from hass.data

if DOMAIN in hass.data and entry.entry_id in hass.data[DOMAIN]:
hass.data[DOMAIN].pop(entry.entry_id)

domain_data = hass.data.get(DOMAIN, {})
remaining_controllers = [
value
for value in domain_data.values()
if isinstance(value, dict) and value.get("controller")
]

if not remaining_controllers:
if hass.services.has_service(DOMAIN, SERVICE_RUN_CALIBRATION):
hass.services.async_remove(DOMAIN, SERVICE_RUN_CALIBRATION)
if hass.services.has_service(DOMAIN, SERVICE_CANCEL_CALIBRATION):
hass.services.async_remove(DOMAIN, SERVICE_CANCEL_CALIBRATION)
domain_data.pop("_services_registered", None)

return unload_ok


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")
116 changes: 116 additions & 0 deletions custom_components/boiler_controller/boiler_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Client for interacting with the Boiler Controller module via HTTP API."""
from __future__ import annotations

import logging
from typing import Any, Dict, Optional

import aiohttp

from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession

_LOGGER = logging.getLogger(__name__)


class BoilerClient:
"""HTTP API client for the Boiler Controller module."""

def __init__(self, hass: HomeAssistant, host: str) -> None:
self.hass = hass
# host can be IP or mDNS hostname, e.g. "192.168.1.100"
# or "boiler-controller-abcd1234.local"
self._host = host.strip().rstrip("/")
self._base_url = f"http://{self._host}"
self._session = async_get_clientsession(hass)

@property
def host(self) -> str:
return self._host

async def async_get_status(self) -> Optional[Dict[str, Any]]:
"""Fetch /api/status from the module.

Returns a dict like:
{
"power": 1320,
"heatingPercentage": 60,
"temperature": 65.0,
"total": 12345,
"rssi": -50
}
"""
url = f"{self._base_url}/api/status"
try:
async with self._session.get(
url, timeout=aiohttp.ClientTimeout(total=10)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
_LOGGER.debug("Module status: %s", data)
return data
_LOGGER.warning("Module /api/status returned HTTP %s", resp.status)
except aiohttp.ClientError as err:
_LOGGER.warning("Module /api/status error: %s", err)
except Exception as err: # pylint: disable=broad-except
_LOGGER.error("Unexpected error fetching /api/status: %s", err)
return None

async def async_get_system(self) -> Optional[Dict[str, Any]]:
"""Fetch /api/system from the module.

Returns a dict like:
{
"system": {
"firmwareVersion": 1,
"cpuFrequency": "240 MHz",
"ip": "192.168.1.123",
"currentDateTime": "2026-04-23 20:15:00",
"upSince": "2026-04-22 11:03:18",
"wifiStrength": -58
}
}
"""
url = f"{self._base_url}/api/system"
try:
async with self._session.get(
url, timeout=aiohttp.ClientTimeout(total=10)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
_LOGGER.debug("Module system info: %s", data)
return data
_LOGGER.warning("Module /api/system returned HTTP %s", resp.status)
except aiohttp.ClientError as err:
_LOGGER.warning("Module /api/system error: %s", err)
except Exception as err: # pylint: disable=broad-except
_LOGGER.error("Unexpected error fetching /api/system: %s", err)
return None

async def async_set_heat(self, percentage: int) -> bool:
"""Set heating percentage via /api/heat?percentage=XX (0-100)."""
percentage = max(0, min(100, int(percentage)))
url = f"{self._base_url}/api/heat"
try:
async with self._session.get(
url,
params={"percentage": percentage},
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status == 200:
_LOGGER.debug("Set heating to %s%%", percentage)
return True
_LOGGER.warning(
"Module /api/heat?percentage=%s returned HTTP %s",
percentage,
resp.status,
)
except aiohttp.ClientError as err:
_LOGGER.warning("Module /api/heat error: %s", err)
except Exception as err: # pylint: disable=broad-except
_LOGGER.error("Unexpected error calling /api/heat: %s", err)
return False

async def async_test_connection(self) -> bool:
"""Test connectivity by fetching /api/status."""
status = await self.async_get_status()
return status is not None
Loading
Loading