From a2f40f81ba464dd2a43e1d850c14a80d1186af5a Mon Sep 17 00:00:00 2001 From: Pierre Date: Sat, 10 Oct 2026 15:27:31 +0200 Subject: [PATCH] first version of the boiler controlled by ebusd --- apps/ad_toolbox | 2 +- apps/boilerclimate.py | 278 ++++++++++++++++++ apps/boilerregulator.py | 86 ++++++ apps/global/heating.yaml | 32 +- docs/radiator-heating.md | 107 +++++++ .../packages/radiator_heating_mqtt.yaml | 47 +++ tests/test_boilerclimate.py | 269 +++++++++++++++++ 7 files changed, 819 insertions(+), 2 deletions(-) create mode 100644 apps/boilerclimate.py create mode 100644 apps/boilerregulator.py create mode 100644 docs/radiator-heating.md create mode 100644 homeassistant/packages/radiator_heating_mqtt.yaml create mode 100644 tests/test_boilerclimate.py diff --git a/apps/ad_toolbox b/apps/ad_toolbox index cf8839d..5ae930c 160000 --- a/apps/ad_toolbox +++ b/apps/ad_toolbox @@ -1 +1 @@ -Subproject commit cf8839db6a542defb3e174957b62a2613182cd0a +Subproject commit 5ae930c71035fc9ac2b2bdfc5cc62b69bd3ac3ba diff --git a/apps/boilerclimate.py b/apps/boilerclimate.py new file mode 100644 index 0000000..d27661b --- /dev/null +++ b/apps/boilerclimate.py @@ -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() diff --git a/apps/boilerregulator.py b/apps/boilerregulator.py new file mode 100644 index 0000000..59c0301 --- /dev/null +++ b/apps/boilerregulator.py @@ -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) diff --git a/apps/global/heating.yaml b/apps/global/heating.yaml index f193b1e..adbd018 100644 --- a/apps/global/heating.yaml +++ b/apps/global/heating.yaml @@ -216,4 +216,34 @@ guestroom_heatpump: sensor: input_select.guestroom_heatpump_override reset_events: - good_morning - - good_bye \ No newline at end of file + - 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 diff --git a/docs/radiator-heating.md b/docs/radiator-heating.md new file mode 100644 index 0000000..9cab7e7 --- /dev/null +++ b/docs/radiator-heating.md @@ -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 diff --git a/homeassistant/packages/radiator_heating_mqtt.yaml b/homeassistant/packages/radiator_heating_mqtt.yaml new file mode 100644 index 0000000..53271ae --- /dev/null +++ b/homeassistant/packages/radiator_heating_mqtt.yaml @@ -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 diff --git a/tests/test_boilerclimate.py b/tests/test_boilerclimate.py new file mode 100644 index 0000000..d9248c3 --- /dev/null +++ b/tests/test_boilerclimate.py @@ -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()