first version of the boiler controlled by ebusd

This commit is contained in:
Pierre committed 2026-10-10 15:27:31 +02:00
1 parent c21291e9dd
commit a2f40f81ba
7 files changed
+818 -1

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
"""MQTT-discovered boiler climate; MQTT transport is Home Assistant's service.
Incoming MQTT is forwarded by the companion Home Assistant automation. All
callbacks run on the app's pinned AppDaemon thread; no broker credentials.
"""
from datetime import datetime, timezone
import json
from pathlib import Path
import time
import appdaemon.plugins.hass.hassapi as hass
from boilerregulator import BoilerRegulator, finite, setmode
from logger_interface import LoggerInterface
class BoilerClimate(hass.Hass, LoggerInterface):
def initialize(self):
self.initialize_logger_interface(self.get_ad_api(), ascii_encode=False)
self.base = self.args.get('mqtt_topic', 'appdaemon/radiator_heating').rstrip('/')
self.boiler = self.args.get('boiler_topic', 'themaplus-condens').rstrip('/')
self.uid = self.args.get('unique_id', 'appdaemon_radiator_heating')
self.title = self.args.get('display_name', 'Chauffage radiateurs')
self.indoor = self.args['indoor_sensor']
self.outdoor = self.args['outdoor_sensor']
self.interval = finite(self.args.get('command_interval', 60))
if not 10 <= self.interval <= 300:
raise ValueError('command_interval must be between 10 and 300 seconds')
self.min_target, self.max_target = 10.0, 23.0
self.target = finite(self.args.get('target_temperature', 18))
self.mode = self.args.get('initial_mode', 'off')
if self.mode not in ('off', 'heat') or not self.min_target <= self.target <= self.max_target:
raise ValueError('Invalid initial mode or room target')
self.send_commands = self.args.get('send_commands', False) is True
self.controller = BoilerRegulator(**self.args.get('regulation', {}))
self.state_file = Path(str(self.AD.app_dir)) / 'data' / (self.uid + '.json')
self._restore()
self.previous_time = time.monotonic()
self.last_send = 0.0
self.last_payload = None
self.last_command_at = None
self.last_echo_at = None
self.telemetry = {}
self.sensor_error = None
self.last_status_time = 0.0
self._state = None
self.listen_event(self._mqtt_event, 'APPDAEMON_BOILER_MQTT')
self.listen_state(self._sensor_changed, self.indoor, attribute='all')
self.listen_state(self._sensor_changed, self.outdoor, attribute='all')
self._discovery()
self.run_every(self._tick, 'now', self.interval)
self._control()
self.log_info(f'Boiler climate ready: MQTT writes={self.send_commands}, room target={self.target} °C')
def _publish(self, topic, payload, retain=False):
if not isinstance(payload, str):
payload = json.dumps(payload, allow_nan=False, ensure_ascii=False)
self.call_service('mqtt/publish', topic=topic, payload=payload, qos=0, retain=retain)
def _discovery(self):
device = dict(identifiers=[self.uid], name=self.title,
manufacturer='AppDaemon', model='Weather compensation + room PI')
common = dict(device=device, availability_topic=self.base + '/availability')
config = dict(common, name=None, unique_id=self.uid,
default_entity_id='climate.radiator_heating',
modes=['off', 'heat'], temperature_unit='C', precision=0.1,
temp_step=0.5, min_temp=self.min_target, max_temp=self.max_target,
mode_command_topic=self.base + '/command/mode',
temperature_command_topic=self.base + '/command/temperature',
mode_state_topic=self.base + '/state',
mode_state_template='{{ value_json.mode }}',
temperature_state_topic=self.base + '/state',
temperature_state_template='{{ value_json.target }}',
current_temperature_topic=self.base + '/state',
current_temperature_template='{{ value_json.indoor }}',
action_topic=self.base + '/state', action_template='{{ value_json.action }}',
json_attributes_topic=self.base + '/attributes', retain=False, qos=0)
self._publish('homeassistant/climate/' + self.uid + '/config', config, True)
for key, label in [('flow_requested', 'Température eau demandée'),
('curve', 'Loi d’eau'), ('proportional', 'Correction proportionnelle'),
('integral', 'Correction intégrale')]:
self._publish('homeassistant/sensor/' + self.uid + '/' + key + '/config',
dict(common, name=label, unique_id=self.uid + '_' + key,
state_topic=self.base + '/state',
value_template='{{ value_json.' + key + ' }}',
unit_of_measurement='°C', entity_category='diagnostic'), True)
self._publish('homeassistant/sensor/' + self.uid + '/reason/config',
dict(device=device, name='État de la régulation', unique_id=self.uid + '_reason',
state_topic=self.base + '/state', value_template='{{ value_json.reason }}',
json_attributes_topic=self.base + '/state',
json_attributes_template='{{ {"sensor_error": value_json.sensor_error} | tojson }}',
expire_after=180, entity_category='diagnostic'), True)
self._publish('homeassistant/sensor/' + self.uid + '/heartbeat/config',
dict(name='Heartbeat', device=device, unique_id=self.uid + '_heartbeat',
state_topic=self.base + '/heartbeat', expire_after=180,
entity_category='diagnostic', device_class='timestamp'), True)
def _restore(self):
try:
data = json.loads(self.state_file.read_text())
if not isinstance(data, dict) or data.get('mode') not in ('heat', 'off'):
raise ValueError('Invalid saved climate mode')
self.mode = data['mode']
target = finite(data['target'])
if not self.min_target <= target <= self.max_target:
raise ValueError('Saved room target out of bounds')
self.target = target
except FileNotFoundError:
pass # First start uses the configured initial mode and target.
except (OSError, ValueError, KeyError, TypeError) as exc:
self.mode = 'off'
self.log_warning(f'Could not restore climate state; heating remains off: {exc}')
def _save(self):
self.state_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self.state_file.with_suffix('.tmp')
tmp.write_text(json.dumps(dict(mode=self.mode, target=self.target)))
tmp.replace(self.state_file)
def _save_safely(self):
try:
self._save()
except OSError as exc:
self.log_error(f'Could not save climate state; boiler control continues: {exc}')
def _sensor_value(self, entity):
data = self.get_state(entity, attribute='all')
if not isinstance(data, dict):
raise ValueError(f'{entity}: sensor missing')
attributes = data.get('attributes', {})
name = attributes.get('friendly_name')
label = f'{entity} ({name})' if name else entity
if attributes.get('unit_of_measurement') != '°C':
raise ValueError(f'{label}: expected Celsius unit')
try:
value = finite(data.get('state'))
except (ValueError, TypeError):
raise ValueError(f"{label}: invalid state {data.get('state')!r}") from None
if not -40 <= value <= 60:
raise ValueError(f'{label}: implausible temperature {value} °C')
return value
def _sensor_changed(self, entity, attribute, old, new, kwargs):
self._control()
def _tick(self, kwargs=None):
self._control()
def _mqtt_event(self, event, data, kwargs):
topic, payload = data.get('topic'), data.get('payload', '')
if topic == 'homeassistant/status' and payload == 'online':
self._discovery()
self._control()
return
if topic == self.base + '/command/mode':
if payload not in ('off', 'heat'):
self.log_warning('Rejected invalid climate mode')
return
self.mode = payload
self.controller.reset()
self._control(force=True)
self._save_safely()
elif topic == self.base + '/command/temperature':
try:
target = finite(payload)
if not self.min_target <= target <= self.max_target:
raise ValueError('Target out of bounds')
except (ValueError, TypeError):
self.log_warning('Rejected invalid room target')
return
self.target = target
self.controller.reset()
self._control(force=True)
self._save_safely()
elif topic and topic.startswith(self.boiler + '/bai/'):
try:
value = payload if isinstance(payload, dict) else json.loads(payload)
if not isinstance(value, dict):
return
except (ValueError, TypeError):
return
name = topic.rsplit('/', 1)[-1]
self.telemetry[name] = value
if name == 'Statenumber':
self.last_status_time = time.monotonic()
if name == 'SetMode' and self.last_payload is not None:
if all(value.get(k) == v for k, v in self.last_payload.items()):
self.last_echo_at = datetime.now(timezone.utc).isoformat()
# Telemetry is diagnostic only: it never renews a boiler command.
if name == 'Statenumber':
self._publish_state()
self._attributes()
def _publish_state(self, now=None):
if getattr(self, '_state', None) is None:
return
now = time.monotonic() if now is None else now
status = self.telemetry.get('Statenumber', {}).get('value')
heating = status in (4, '4') and now - self.last_status_time < 180
self._state['action'] = 'off' if self.mode == 'off' else ('heating' if heating else 'idle')
self._publish(self.base + '/state', self._state.copy(), True)
def _attributes(self):
self._publish(self.base + '/attributes', dict(
indoor_sensor=self.indoor, outdoor_sensor=self.outdoor,
commands_enabled=self.send_commands,
sensor_error=getattr(self, 'sensor_error', None),
last_command_at=self.last_command_at, last_c6_echo_at=self.last_echo_at,
last_command=self.last_payload, boiler_telemetry=self.telemetry,
note='C6 echo confirms transmission, not effective boiler setpoint'), True)
def _control(self, force=False):
now = time.monotonic()
dt = min(max(0, now - self.previous_time), self.interval * 2)
self.previous_time = now
indoor = outdoor = None
result = None
readings, errors = {}, []
for entity in (self.indoor, self.outdoor):
try:
readings[entity] = self._sensor_value(entity)
except (ValueError, TypeError, KeyError) as exc:
errors.append(str(exc))
indoor, outdoor = readings.get(self.indoor), readings.get(self.outdoor)
valid = not errors
sensor_error = '; '.join(errors) if errors else None
# Log once per set of failing sensors, not every minute as age increases.
signature = tuple((entity, entity in readings) for entity in (self.indoor, self.outdoor))
previous = getattr(self, '_sensor_fault_signature', None)
if not valid and signature != previous:
self.log_error(f'Heating inhibited: sensor fault: {sensor_error}')
elif valid and previous is not None and previous != signature:
self.log_info('Temperature sensors recovered; regulation can resume. '
f"Previous fault: {getattr(self, 'sensor_error', None)}")
self._sensor_fault_signature = signature
self.sensor_error = sensor_error
if self.mode == 'off' or not valid:
self.controller.reset()
reason = 'off' if self.mode == 'off' else 'invalid_sensor'
enabled, flow = False, 0
else:
result = self.controller.update(self.target, indoor, outdoor, dt)
enabled, flow, reason = result.enabled, result.flow, result.reason
command = setmode(flow, enabled)
if self.send_commands and (force or command != self.last_payload or now - self.last_send >= self.interval):
self._publish(self.boiler + '/bai/SetMode/set', command, False)
if command != self.last_payload:
self.log_info(f"Heating request: enabled={enabled}, flow={command['flowtempdesired']} °C, reason={reason}")
self.last_payload, self.last_send = command, now
self.last_command_at = datetime.now(timezone.utc).isoformat()
self._state = dict(mode=self.mode, target=self.target, indoor=indoor,
outdoor=outdoor, reason=reason, sensor_error=self.sensor_error,
heating_requested=enabled, flow_requested=flow if enabled else 0,
curve=round(result.curve, 2) if result else 0,
proportional=round(result.proportional, 2) if result else 0,
integral=round(result.integral, 3) if result else 0)
self._publish_state(now)
self._attributes()
self._publish(self.base + '/availability', 'online' if valid else 'offline', True)
self._publish(self.base + '/heartbeat', datetime.now(timezone.utc).isoformat())
# Request actual status, without writing or renewing SetMode.
self._publish(self.boiler + '/bai/Statenumber/get', {})
self._publish(self.boiler + '/bai/FlowTemp/get', {})
def terminate(self):
# An orderly shutdown explicitly inhibits space heating; a crash relies
# on boiler command expiration. No retained actuator command, ever.
if not hasattr(self, 'base'):
return
try:
if self.send_commands:
self._publish(self.boiler + '/bai/SetMode/set', setmode(0, False))
except Exception as exc:
self.log_error(f'Could not publish shutdown; boiler expiration remains the fallback: {exc}')
try:
self._publish(self.base + '/availability', 'offline', True)
except Exception as exc:
self.log_warning(f'Could not publish offline availability: {exc}')
self._save_safely()
+86
View File
@@ -0,0 +1,86 @@
"""Weather compensation and room PI correction, expressed in water degrees."""
from dataclasses import dataclass
import math
def finite(value):
value = float(value)
if not math.isfinite(value):
raise ValueError('Temperature must be finite')
return value
def clamp(value, low, high):
return max(low, min(high, value))
@dataclass
class Regulation:
flow: float
enabled: bool
curve: float
proportional: float
integral: float
reason: str
class BoilerRegulator:
def __init__(self, *, slope=1.5, offset=10, kp=5, ki=0.2,
integral_limit=8, correction_limit=12, min_flow=30,
max_flow=50, overheat=0.5, restart_margin=0.1):
self.slope, self.offset = finite(slope), finite(offset)
self.kp, self.ki = finite(kp), finite(ki)
self.integral_limit, self.correction_limit = finite(integral_limit), finite(correction_limit)
self.min_flow, self.max_flow = finite(min_flow), finite(max_flow)
self.overheat, self.restart_margin = finite(overheat), finite(restart_margin)
if not (0 <= self.min_flow < self.max_flow <= 80):
raise ValueError('Require 0 <= min_flow < max_flow <= 80')
if min(self.slope, self.kp, self.ki, self.integral_limit, self.correction_limit) < 0:
raise ValueError('Gains and limits must be non-negative')
if not 0 <= self.restart_margin < self.overheat:
raise ValueError('Require 0 <= restart_margin < overheat')
self.integral = 0.0
self.enabled = False
def reset(self):
self.integral = 0.0
self.enabled = False
def update(self, target, indoor, outdoor, dt):
target, indoor, outdoor, dt = map(finite, (target, indoor, outdoor, dt))
error = target - indoor
curve = target + self.slope * (target - outdoor) + self.offset
proportional = self.kp * error
# ki is water °C / (room °C * hour). No accumulated downtime.
candidate = clamp(self.integral + self.ki * error * max(0, dt) / 3600,
-self.integral_limit, self.integral_limit)
raw = curve + clamp(proportional + candidate, -self.correction_limit, self.correction_limit)
# Anti-windup for BOTH the water bounds and the correction bound.
saturated_high = raw >= self.max_flow or proportional + candidate >= self.correction_limit
saturated_low = raw <= self.min_flow or proportional + candidate <= -self.correction_limit
if not ((saturated_high and error > 0) or (saturated_low and error < 0)):
self.integral = candidate
raw = curve + clamp(proportional + self.integral, -self.correction_limit, self.correction_limit)
if indoor >= target + self.overheat:
self.enabled = False
reason = 'room_overheat'
elif raw <= self.min_flow - 1:
self.enabled = False
reason = 'low_demand'
elif not self.enabled:
if indoor <= target + self.restart_margin and raw >= self.min_flow + 1:
self.enabled = True
reason = 'regulating' if self.enabled else 'waiting_for_demand'
else:
reason = 'regulating'
flow = round(clamp(raw, self.min_flow, self.max_flow) * 2) / 2
return Regulation(flow, self.enabled, curve, proportional, self.integral, reason)
def setmode(flow, enabled):
"""Full C6 SetMode payload. No new domestic hot-water setpoint."""
return dict(hcmode='auto', flowtempdesired=finite(flow) if enabled else 0,
hwctempdesired=None, hwcflowtempdesired=None,
disablehc=0 if enabled else 1, disablehwctapping=0,
disablehwcload=0, remotecontrolhcpump=0,
releasebackup=0, releasecooling=0)
+30
View File
@@ -217,3 +217,33 @@ guestroom_heatpump:
reset_events:
- good_morning
- good_bye
radiator_heating:
module: boilerclimate
class: BoilerClimate
display_name: Chauffage radiateurs
unique_id: appdaemon_radiator_heating
indoor_sensor: sensor.corridor_temperature
outdoor_sensor: sensor.ext_front_temperature
target_temperature: 18
initial_mode: heat
# Radiator heating enabled with the room-thermostat bridge CLOSED.
# false keeps the climate/diagnostics but suppresses SetMode writes.
send_commands: true
command_interval: 60
mqtt_topic: appdaemon/radiator_heating
boiler_topic: themaplus-condens
# Starting values, to tune from observations with radiator circulation enabled.
regulation:
slope: 1.5
offset: 10
kp: 5
ki: 0.2 # water °C / (room °C * hour)
integral_limit: 8
correction_limit: 12
min_flow: 30
max_flow: 50
overheat: 0.5
restart_margin: 0.1
+107
View File
@@ -0,0 +1,107 @@
# Chauffage radiateurs — AppDaemon et C6
L'app `radiator_heating` expose `climate.radiator_heating` par MQTT Discovery.
Les publications utilisent le service `mqtt.publish` de Home Assistant, comme
l'ancienne implémentation de SmartObject. Aucun identifiant MQTT supplémentaire
ni plugin MQTT AppDaemon n'est nécessaire.
## Fichiers
- `apps/boilerclimate.py` : MQTT Discovery, consigne, mode, mesures et commandes.
- `apps/boilerregulator.py` : calcul indépendant de la température d'eau.
- `apps/global/heating.yaml`, bloc `radiator_heating` : paramètres actifs de l'app.
- `homeassistant/packages/radiator_heating_mqtt.yaml` : copie des deux
automatisations installées via l'API de configuration de Home Assistant.
Ne pas inclure aussi cette copie comme package : les automatisations existent
déjà dans Home Assistant. Leur modification dans cette copie ne les redéploie pas.
## Régulation
Avec S la consigne ambiante, Ti la température du couloir et Te celle de devant :
courbe = S + pente × (S − Te) + décalage
correction P = kp × (S − Ti)
correction I += ki × (S − Ti) × durée_en_heures
demande = courbe + correction bornée(P + I)
La sortie est arrondie au demi-degré et limitée entre 30 et 50 °C initialement.
`ki` est exprimé en degrés d'eau par degré d'écart intérieur et par heure.
Les bornes de sortie et de correction disposent d'une protection anti-windup.
L'intégrale est remise à zéro à l'arrêt, en cas de mesure invalide, au changement
manuel de consigne et au redémarrage. Consigne et mode sont persistés dans
`apps/data/appdaemon_radiator_heating.json`, exclu de Git.
Une sauvegarde absente au premier lancement utilise la configuration initiale.
Une sauvegarde illisible ou invalide impose le mode `off` avec un avertissement.
Une erreur d'écriture est journalisée sans bloquer la commande chaudière,
notamment l'arrêt demandé par l'utilisateur ou l'arrêt ordonné de l'app.
À l'équilibre, la chauffe peut rester demandée même lorsque Ti = S.
L'app inhibe le chauffage si Ti >= S + 0,5 °C, ou si la demande calculée descend
sous la température minimale utile (avec une hystérésis de 1 °C d'eau).
Après arrêt, le redémarrage exige Ti <= S + 0,1 °C et une demande suffisante.
Les gains sont des valeurs initiales à ajuster après les essais de circulation,
pas des coefficients identifiés pour cette maison.
Les deux capteurs doivent fournir des valeurs finies en °C, entre −40 et 60 °C.
Une température inchangée, même ancienne, reste valide : ces sondes peuvent
ne publier qu'en cas de changement. Aucun délai sur l'âge de la mesure n'est
appliqué. Les états indisponibles/inconnus, les valeurs invalides et les unités
incorrectes provoquent toujours l'inhibition. Une sonde déconnectée conservant
une valeur plausible n'est donc pas détectée par ce contrôle.
Un défaut publie un log ERROR avec l'identifiant, le nom du capteur et sa cause
(état invalide, unité ou valeur incohérente). Le log est émis
à l'apparition du défaut ou au changement des capteurs concernés, sans répétition
chaque minute. Le détail actualisé reste disponible dans l'attribut `sensor_error`
du capteur « État de la régulation », même quand le climate est indisponible.
Un log INFO signale le rétablissement. L'app utilise le pipeline standard
`LoggerInterface` (`log_info`, `log_warning`, `log_error`) ; les erreurs sont
également envoyées dans `error_log`. L'option `ascii_encode=False`, propre à
cette app, conserve les accents et le symbole °C.
## MQTT et disponibilité
- Configuration : `homeassistant/climate/appdaemon_radiator_heating/config`.
- Commandes utilisateur : `appdaemon/radiator_heating/command/mode` (`heat`/`off`)
et `/command/temperature` (consigne ambiante entre 10 et 23 °C).
- État et diagnostics : `appdaemon/radiator_heating/state` et `/attributes`.
- Chaudière : `themaplus-condens/bai/SetMode/set`.
L'automatisation de retour transmet les commandes MQTT et les retours chaudière
à l'événement `APPDAEMON_BOILER_MQTT`, écouté par l'app. Le watchdog marque le
climate indisponible après trois minutes sans heartbeat. Il ne commande PAS
la chaudière : il signale l'arrêt de l'app. Configuration et états sont retenus ;
les commandes utilisateur et chaudière ne doivent jamais être retenues.
`SetMode` est renouvelé chaque minute et lors d'un changement de demande.
Les retours de télémétrie ne renouvellent pas la commande. Un arrêt ordonné
publie une inhibition du chauffage ; un crash repose sur l'expiration observée
de la commande chaudière. Les champs de consigne ECS sont `null` et les bits
d'inhibition sanitaire restent à zéro.
`heating_requested` désigne la demande de l'app. L'action `heating` du climate
n'est annoncée qu'avec un état chaudière S.04 récent et est actualisée dès
réception du statut, sans recalcul ni renouvellement de commande ; une chauffe sanitaire
n'est pas comptée comme chauffage des radiateurs. Sans télémétrie récente,
`idle` ne constitue pas une preuve d'absence de flamme : consulter aussi la
chaudière. L'écho SetMode confirme une transmission C6, pas la consigne effective.
## Essais actuels
Les écritures sont autorisées (`send_commands: true`), pont thermostat fermé.
Le climate démarre en mode `heat`, consigne 18 °C, sauf état utilisateur persisté.
Un couloir plus chaud que la consigne entraîne normalement une inhibition.
Le retour au réglage local de 10 °C après environ 15 minutes sans renouvellement
a été observé pont ouvert. L'arrêt réel après perte de communication reste à
vérifier avec circulation autorisée ; ce délai n'est pas une coupure immédiate.
Un premier essai pont fermé avec une consigne ambiante de 23 °C a produit une
demande d'eau à 46 °C et un démarrage de la chauffe. Les réglages restent à
ajuster selon l'évolution réelle des températures. Ne pas modifier les mesures
réelles pour simuler la chauffe.
`send_commands: false` permet de conserver l'interface et les calculs sans écrire
SetMode. Les lectures de télémétrie continuent.
## Vérification
python -m unittest discover -s tests -p test_boilerclimate.py -v
@@ -0,0 +1,47 @@
# Installed through HA's automation configuration API. This is the reviewable
# source copy; do not also include it as a package if the automations exist.
automation:
- id: appdaemon_radiator_heating_bridge
alias: AppDaemon - Chauffage radiateurs - Retour MQTT
description: Transmet les commandes du climate et les retours C6 à AppDaemon.
triggers:
- trigger: mqtt
topic: appdaemon/radiator_heating/command/+
- trigger: mqtt
topic: themaplus-condens/bai/SetMode
- trigger: mqtt
topic: themaplus-condens/bai/Statenumber
- trigger: mqtt
topic: themaplus-condens/bai/FlowTemp
- trigger: mqtt
topic: homeassistant/status
actions:
- event: APPDAEMON_BOILER_MQTT
event_data:
topic: '{{ trigger.topic }}'
payload: '{{ trigger.payload }}'
mode: queued
max: 50
- id: appdaemon_radiator_heating_watchdog
alias: AppDaemon - Chauffage radiateurs - Disponibilité
description: Marque le climate indisponible après trois minutes sans heartbeat.
triggers:
- trigger: mqtt
topic: appdaemon/radiator_heating/heartbeat
id: heartbeat
- trigger: homeassistant
event: start
id: startup
actions:
- if:
- condition: template
value_template: "{{ trigger.id == 'heartbeat' }}"
then:
- delay:
minutes: 3
- action: mqtt.publish
data:
topic: appdaemon/radiator_heating/availability
payload: offline
retain: true
mode: restart
+269
View File
@@ -0,0 +1,269 @@
import json
from datetime import datetime, timedelta, timezone
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps'))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox'))
from boilerregulator import BoilerRegulator, setmode
from boilerclimate import BoilerClimate
class RegulatorTests(unittest.TestCase):
def test_equilibrium_retains_heat_at_target(self):
r = BoilerRegulator()
v = r.update(18, 18, 5, 60)
self.assertTrue(v.enabled)
self.assertEqual(v.flow, 47.5)
def test_colder_outdoors_and_room_increase_water_request(self):
mild = BoilerRegulator().update(18, 18, 10, 0)
cold = BoilerRegulator().update(18, 18, 5, 0)
cold_room = BoilerRegulator().update(18, 17, 10, 0)
self.assertGreater(cold.flow, mild.flow)
self.assertGreater(cold_room.flow, mild.flow)
def test_persistent_error_integrates_in_water_degrees(self):
r = BoilerRegulator(max_flow=65)
v = r.update(18, 17.5, 10, 3600)
self.assertAlmostEqual(v.integral, 0.1)
def test_upper_saturation_does_not_wind_up(self):
r = BoilerRegulator()
for _ in range(1000):
v = r.update(18, 12, -10, 60)
self.assertEqual(v.flow, 50)
self.assertEqual(v.integral, 0)
def test_overheat_and_low_demand_stop_with_hysteresis(self):
r = BoilerRegulator()
self.assertTrue(r.update(18, 18, 5, 0).enabled)
self.assertFalse(r.update(18, 18.6, 5, 60).enabled)
self.assertFalse(r.update(18, 18.2, 5, 60).enabled)
self.assertTrue(r.update(18, 18, 5, 60).enabled)
self.assertFalse(r.update(18, 18, 20, 60).enabled)
def test_payload_preserves_sanitary_fields(self):
a, b = setmode(40, True), setmode(0, False)
self.assertEqual(a['disablehc'], 0)
self.assertEqual(b['disablehc'], 1)
for p in [a, b]:
self.assertIsNone(p['hwctempdesired'])
self.assertIsNone(p['hwcflowtempdesired'])
self.assertEqual(p['disablehwctapping'], 0)
self.assertEqual(p['disablehwcload'], 0)
def test_rejects_nonfinite_inputs(self):
for value in [float('nan'), float('inf')]:
with self.assertRaises(ValueError):
BoilerRegulator().update(18, value, 5, 60)
class ClimateTests(unittest.TestCase):
def app(self):
# Test the actual app callbacks without starting an AppDaemon server.
app = object.__new__(BoilerClimate)
app.base, app.boiler = 'appdaemon/test', 'boiler'
app.mode, app.target = 'heat', 18
app.indoor, app.outdoor = 'sensor.indoor', 'sensor.outdoor'
app.min_target, app.max_target = 10, 23
app.interval = 60
app.controller = BoilerRegulator()
app.send_commands = True
app.last_payload = None
app.last_send = app.previous_time = 1000
app.last_command_at = app.last_echo_at = None
app.telemetry, app.last_status_time = {}, 0
app._publish = Mock()
app._save = Mock()
app.log = Mock()
app.initialize_logger_interface(app, ascii_encode=False)
readings = {app.indoor: 18, app.outdoor: 10}
app._sensor_value = lambda entity: readings[entity]
return app
def writes(self, app):
return [c for c in app._publish.call_args_list if c.args[0] == 'boiler/bai/SetMode/set']
def test_single_change_and_periodic_refresh_only(self):
a = self.app()
with patch('boilerclimate.time.monotonic', return_value=1000):
a._control()
self.assertEqual(len(self.writes(a)), 1)
with patch('boilerclimate.time.monotonic', return_value=1030):
a._control()
self.assertEqual(len(self.writes(a)), 1)
with patch('boilerclimate.time.monotonic', return_value=1060):
a._control()
self.assertEqual(len(self.writes(a)), 2)
for call in self.writes(a):
self.assertFalse(call.args[2])
def test_invalid_sensor_explicitly_inhibits_heat(self):
a = self.app()
a._sensor_value = Mock(side_effect=ValueError('unavailable'))
a._control()
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
self.assertEqual(a.controller.integral, 0)
def test_observation_mode_never_writes_boiler(self):
a = self.app()
a.send_commands = False
a._control()
self.assertEqual(self.writes(a), [])
def test_room_target_rejects_nan_and_out_of_bounds(self):
a = self.app()
for p in ['nan', 'inf', '30', 'garbage']:
a._mqtt_event('', dict(topic=a.base + '/command/temperature', payload=p), {})
self.assertEqual(a.target, 18)
self.assertFalse(a._save.called)
self.assertEqual(self.writes(a), [])
def test_off_command_sends_stop(self):
a = self.app()
a._mqtt_event('', dict(topic=a.base + '/command/mode', payload='off'), {})
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
def test_native_ha_template_payload_is_accepted(self):
a = self.app()
a._mqtt_event('', dict(topic='boiler/bai/Statenumber', payload={'value': 30}), {})
self.assertEqual(a.telemetry['Statenumber'], {'value': 30})
self.assertEqual(self.writes(a), [])
def reading_app(self, state='18', age=0):
a = self.app()
stamp = (datetime.now(timezone.utc) - timedelta(seconds=age)).isoformat()
a.get_state = Mock(return_value={
'state': state,
'attributes': {'unit_of_measurement': '°C', 'friendly_name': 'Couloir Température'},
'last_updated': stamp,
})
return a
def test_unchanged_temperature_is_valid_even_after_days(self):
a = self.reading_app(age=86400 * 3)
self.assertEqual(BoilerClimate._sensor_value(a, a.indoor), 18)
def test_unavailable_is_invalid_without_waiting_two_hours(self):
a = self.reading_app(state='unavailable', age=1)
with self.assertRaisesRegex(ValueError, r'sensor.indoor.*invalid state.*unavailable'):
BoilerClimate._sensor_value(a, a.indoor)
def test_missing_timestamp_does_not_invalidate_temperature(self):
a = self.reading_app()
a.get_state.return_value.pop('last_updated')
self.assertEqual(BoilerClimate._sensor_value(a, a.indoor), 18)
def test_fault_logs_error_once_then_logs_recovery(self):
a = self.app()
a._sensor_value = Mock(side_effect=ValueError('sensor.indoor (Couloir): unavailable'))
a._control()
a._control()
errors = [c for c in a.log.call_args_list
if c.kwargs.get('level') == 'ERROR' and c.kwargs.get('log') == 'main_log']
self.assertEqual(len(errors), 1)
self.assertIn('Couloir', errors[0].args[0])
self.assertFalse(errors[0].kwargs['ascii_encode'])
self.assertTrue(any(c.kwargs.get('log') == 'error_log' for c in a.log.call_args_list))
self.assertIn('sensor.indoor', a.sensor_error)
a._sensor_value = lambda entity: 18 if entity == a.indoor else 10
a._control()
self.assertIsNone(a.sensor_error)
self.assertTrue(any('recovered' in c.args[0] for c in a.log.call_args_list))
def test_telemetry_does_not_refresh_command(self):
a = self.app()
a._mqtt_event('', dict(topic='boiler/bai/SetMode', payload=json.dumps(setmode(40, True))), {})
self.assertEqual(self.writes(a), [])
def test_save_failure_does_not_block_off(self):
a = self.app()
a._save.side_effect = OSError('disk full')
a._mqtt_event('', dict(topic=a.base + '/command/mode', payload='off'), {})
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
self.assertTrue(any('disk full' in c.args[0] for c in a.log.call_args_list))
def test_save_failure_does_not_block_shutdown(self):
a = self.app()
a._save.side_effect = OSError('disk full')
a.terminate()
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
a._publish.assert_any_call(a.base + '/availability', 'offline', True)
def test_shutdown_publish_failure_still_saves_and_marks_offline(self):
a = self.app()
def publish(topic, *args):
if topic == 'boiler/bai/SetMode/set':
raise RuntimeError('service unavailable')
a._publish.side_effect = publish
a.terminate()
a._save.assert_called_once()
a._publish.assert_any_call(a.base + '/availability', 'offline', True)
def test_invalid_persisted_state_starts_off(self):
with tempfile.TemporaryDirectory() as folder:
for saved in [{'mode': 'off', 'target': 'invalid'},
{'mode': 'heat', 'target': 30},
{'target': 18}, [], None]:
with self.subTest(saved=saved):
a = self.app()
a.state_file = Path(folder) / 'state.json'
a.state_file.write_text(json.dumps(saved))
a._restore()
self.assertEqual(a.mode, 'off')
self.assertEqual(a.target, 18)
self.assertTrue(any(c.kwargs.get('level') == 'WARNING'
for c in a.log.call_args_list))
def test_restore_valid_state_and_first_start(self):
with tempfile.TemporaryDirectory() as folder:
a = self.app()
a.state_file = Path(folder) / 'state.json'
a._restore()
self.assertEqual(a.mode, 'heat')
a.state_file.write_text(json.dumps({'mode': 'off', 'target': 19.5}))
a._restore()
self.assertEqual((a.mode, a.target), ('off', 19.5))
def test_corrupt_or_unreadable_state_starts_off(self):
for contents in ['{broken', OSError('permission denied')]:
with self.subTest(contents=contents):
a = self.app()
a.state_file = Mock()
if isinstance(contents, OSError):
a.state_file.read_text.side_effect = contents
else:
a.state_file.read_text.return_value = contents
a._restore()
self.assertEqual(a.mode, 'off')
def test_standard_log_pipeline_preserves_unicode_and_error_routing(self):
a = self.app()
a.log_info('Demande à 40 °C')
a.log_warning('État invalide')
a.log_error('Échec chaudière')
a.log.assert_any_call('Demande à 40 °C', level='INFO', log='main_log', ascii_encode=False)
a.log.assert_any_call('État invalide', level='WARNING', log='main_log', ascii_encode=False)
a.log.assert_any_call('Échec chaudière', level='ERROR', log='error_log', ascii_encode=False)
def test_boiler_status_updates_action_without_advancing_regulation(self):
a = self.app()
a._control()
a._publish.reset_mock()
previous_time, integral = a.previous_time, a.controller.integral
for status, action in [(4, 'heating'), (14, 'idle'), (0, 'idle')]:
a._mqtt_event('', dict(topic='boiler/bai/Statenumber', payload={'value': status}), {})
states = [c.args[1] for c in a._publish.call_args_list if c.args[0] == a.base + '/state']
self.assertEqual(states[-1]['action'], action)
self.assertEqual(self.writes(a), [])
self.assertEqual(a.previous_time, previous_time)
self.assertEqual(a.controller.integral, integral)
if __name__ == '__main__':
unittest.main()