diff --git a/.gitignore b/.gitignore index 7f63403..59aa76f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ *.pyc logs/ apps/data/ +secrets.yaml diff --git a/appdaemon.yaml b/appdaemon.yaml index 71619b9..fda006b 100644 --- a/appdaemon.yaml +++ b/appdaemon.yaml @@ -13,6 +13,29 @@ appdaemon: token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiZThhYWI2ZTlkNmQ0NWU2YTk1ODU5OWJjOWM3MWJkYiIsImlhdCI6MTc3NjEwOTc0NCwiZXhwIjoyMDkxNDY5NzQ0fQ.BorkjFjWlWCZqnUa9-NMUxGsDiupDoRZ3cEgsmeSofM cert_verify: True ws_timeout: 00:10:00 + MQTT: + type: mqtt + namespace: mqtt + client_host: 10.0.0.21 + client_port: 1883 + client_id: appdaemon_radiator_heating + client_user: !secret boiler_mqtt_user + client_password: !secret boiler_mqtt_password + client_timeout: 30 + client_topics: + - appdaemon/radiator_heating/command/+ + - appdaemon/radiator_heating/heartbeat + - themaplus-condens/bai/SetMode + - themaplus-condens/bai/Statenumber + - themaplus-condens/bai/FlowTemp + - homeassistant/status + birth_topic: appdaemon/radiator_heating/connection + birth_payload: online + birth_retain: true + will_topic: appdaemon/radiator_heating/connection + will_payload: offline + will_retain: true + shutdown_payload: offline http: url: http://0.0.0.0:5050 admin: diff --git a/apps/boilerclimate.py b/apps/boilerclimate.py index d27661b..90b4c52 100644 --- a/apps/boilerclimate.py +++ b/apps/boilerclimate.py @@ -1,8 +1,4 @@ -"""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. -""" +"""MQTT-discovered climate using AppDaemon's MQTT plugin and HA sensors.""" from datetime import datetime, timezone import json from pathlib import Path @@ -16,12 +12,22 @@ 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.mqtt_namespace = self.args.get('mqtt_namespace', 'mqtt') + if self.AD.plugins.get_plugin_object(self.mqtt_namespace) is None: + raise RuntimeError('MQTT plugin missing; configure it and restart AppDaemon') self.base = self.args.get('mqtt_topic', 'appdaemon/radiator_heating').rstrip('/') self.boiler = self.args.get('boiler_topic', 'themaplus-condens').rstrip('/') + self.connection_topic = self.args.get('mqtt_connection_topic', self.base + '/connection') 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'] + outdoors = self.args.get('outdoor_sensors') + if outdoors is None: + outdoors = [self.args['outdoor_sensor']] + if not isinstance(outdoors, list) or not outdoors or any(not isinstance(e, str) or not e for e in outdoors): + raise ValueError('outdoor_sensors must be a non-empty list of entity IDs') + self.outdoors = tuple(dict.fromkeys(outdoors)) + self.selected_outdoor = None 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') @@ -43,9 +49,10 @@ class BoilerClimate(hass.Hass, LoggerInterface): self.sensor_error = None self.last_status_time = 0.0 self._state = None - self.listen_event(self._mqtt_event, 'APPDAEMON_BOILER_MQTT') + self.listen_event(self._mqtt_event, 'MQTT_MESSAGE', namespace=self.mqtt_namespace) self.listen_state(self._sensor_changed, self.indoor, attribute='all') - self.listen_state(self._sensor_changed, self.outdoor, attribute='all') + for entity in self.outdoors: + self.listen_state(self._sensor_changed, entity, attribute='all') self._discovery() self.run_every(self._tick, 'now', self.interval) self._control() @@ -54,12 +61,15 @@ class BoilerClimate(hass.Hass, LoggerInterface): 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) + self.call_service('mqtt/publish', topic=topic, payload=payload, qos=0, + retain=retain, namespace=self.mqtt_namespace) 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') + common = dict(device=device, availability=[{'topic': self.base + '/availability'}, + {'topic': self.connection_topic}], + availability_mode='all') config = dict(common, name=None, unique_id=self.uid, default_entity_id='climate.radiator_heating', modes=['off', 'heat'], temperature_unit='C', precision=0.1, @@ -147,6 +157,11 @@ class BoilerClimate(hass.Hass, LoggerInterface): def _mqtt_event(self, event, data, kwargs): topic, payload = data.get('topic'), data.get('payload', '') + if topic is None: + if data.get('state') == 'Connected': + self._discovery() + self._control(force=True) + return if topic == 'homeassistant/status' and payload == 'online': self._discovery() self._control() @@ -201,7 +216,8 @@ class BoilerClimate(hass.Hass, LoggerInterface): def _attributes(self): self._publish(self.base + '/attributes', dict( - indoor_sensor=self.indoor, outdoor_sensor=self.outdoor, + indoor_sensor=self.indoor, outdoor_sensors=list(self.outdoors), + outdoor_sensor=self.selected_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, @@ -215,16 +231,20 @@ class BoilerClimate(hass.Hass, LoggerInterface): indoor = outdoor = None result = None readings, errors = {}, [] - for entity in (self.indoor, self.outdoor): + sensors = (self.indoor, *self.outdoors) + for entity in sensors: 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) + indoor = readings.get(self.indoor) + self.selected_outdoor = (min(self.outdoors, key=readings.__getitem__) + if all(entity in readings for entity in self.outdoors) else None) + outdoor = readings.get(self.selected_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)) + signature = tuple((entity, entity in readings) for entity in sensors) previous = getattr(self, '_sensor_fault_signature', None) if not valid and signature != previous: self.log_error(f'Heating inhibited: sensor fault: {sensor_error}') diff --git a/apps/boilerwatchdog.py b/apps/boilerwatchdog.py new file mode 100644 index 0000000..22f970f --- /dev/null +++ b/apps/boilerwatchdog.py @@ -0,0 +1,42 @@ +"""Independent AppDaemon worker monitoring the boiler climate heartbeat.""" +import time + +import appdaemon.plugins.hass.hassapi as hass +from logger_interface import LoggerInterface +from boilerregulator import finite + + +class BoilerWatchdog(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.mqtt_namespace = self.args.get('mqtt_namespace', 'mqtt') + self.timeout = finite(self.args.get('heartbeat_timeout', 180)) + if self.timeout < 60: + raise ValueError('heartbeat_timeout must be at least 60 seconds') + if self.AD.plugins.get_plugin_object(self.mqtt_namespace) is None: + raise RuntimeError('MQTT plugin missing; configure it and restart AppDaemon') + self.last_heartbeat = None + self.fault = False + self.listen_event(self._mqtt_event, 'MQTT_MESSAGE', namespace=self.mqtt_namespace) + self.run_every(self._check, 'now', 10) + self._check() + + def _mqtt_event(self, event, data, kwargs): + if data.get('topic') == self.base + '/heartbeat': + self.last_heartbeat = time.monotonic() + if self.fault: + self.log_info('Boiler climate heartbeat recovered') + self.fault = False + elif data.get('topic') is None and data.get('state') == 'Connected': + self.last_heartbeat = None + self.fault = False + self._check() + + def _check(self, kwargs=None): + if self.last_heartbeat is None or time.monotonic() - self.last_heartbeat >= self.timeout: + if not self.fault: + self.call_service('mqtt/publish', namespace=self.mqtt_namespace, + topic=self.base + '/availability', payload='offline', qos=0, retain=True) + self.log_warning('Boiler climate unavailable: waiting for a fresh heartbeat') + self.fault = True diff --git a/apps/global/heating.yaml b/apps/global/heating.yaml index adbd018..d7cf29e 100644 --- a/apps/global/heating.yaml +++ b/apps/global/heating.yaml @@ -224,7 +224,9 @@ radiator_heating: display_name: Chauffage radiateurs unique_id: appdaemon_radiator_heating indoor_sensor: sensor.corridor_temperature - outdoor_sensor: sensor.ext_front_temperature + outdoor_sensors: + - sensor.ext_front_temperature + - sensor.ext_back_temperature target_temperature: 18 initial_mode: heat @@ -233,6 +235,8 @@ radiator_heating: send_commands: true command_interval: 60 mqtt_topic: appdaemon/radiator_heating + mqtt_namespace: mqtt + mqtt_connection_topic: appdaemon/radiator_heating/connection boiler_topic: themaplus-condens # Starting values, to tune from observations with radiator circulation enabled. @@ -247,3 +251,10 @@ radiator_heating: max_flow: 50 overheat: 0.5 restart_margin: 0.1 + +radiator_heating_watchdog: + module: boilerwatchdog + class: BoilerWatchdog + mqtt_namespace: mqtt + mqtt_topic: appdaemon/radiator_heating + heartbeat_timeout: 180 diff --git a/docs/radiator-heating.md b/docs/radiator-heating.md index 9cab7e7..dc42d45 100644 --- a/docs/radiator-heating.md +++ b/docs/radiator-heating.md @@ -1,23 +1,36 @@ # 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. +La version MQTT directe utilise le plugin MQTT d'AppDaemon pour les publications +et les abonnements. Le plugin Home Assistant sert à lire les thermomètres. +Les commandes du climate et les retours C6 restent sur les mêmes topics. + +## Configuration MQTT + +Le plugin `MQTT` est configuré dans `appdaemon.yaml`, namespace `mqtt`. +Les identifiants du broker sont dans `secrets.yaml` (exclu de Git) et référencés +par `!secret`. Ils doivent être sauvegardés séparément pour une réinstallation. +Un redémarrage complet du conteneur/service AppDaemon est nécessaire pour charger +un nouveau plugin ; le rechargement des apps ne suffit pas. + +Le climate et son watchdog exigent ce plugin. Les deux anciennes automatisations +Home Assistant ont été supprimées après vérification des commandes et de la +télémétrie en MQTT direct. Aucun relais MQTT dans Home Assistant n'est nécessaire. ## Fichiers - `apps/boilerclimate.py` : MQTT Discovery, consigne, mode, mesures et commandes. +- `apps/boilerwatchdog.py` : surveillance indépendante du heartbeat. - `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. +- `apps/global/heating.yaml`, blocs `radiator_heating` et + `radiator_heating_watchdog` : paramètres actifs des deux apps. +- `appdaemon.yaml`, plugin `MQTT` : connexion broker, abonnements et Last Will. ## Régulation -Avec S la consigne ambiante, Ti la température du couloir et Te celle de devant : +Avec S la consigne ambiante, Ti la température du couloir et Te la plus basse +des températures extérieures (`sensor.ext_front_temperature` et +`sensor.ext_back_temperature`, configurées dans `outdoor_sensors`) : courbe = S + pente × (S − Te) + décalage correction P = kp × (S − Ti) @@ -42,7 +55,12 @@ 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. +Le capteur intérieur et les deux capteurs extérieurs doivent fournir des valeurs +finies en °C, entre −40 et 60 °C. Le minimum extérieur est recalculé à chaque +mise à jour de l'un des capteurs et à chaque cycle. L'attribut `outdoor_sensors` +liste les sondes utilisées et `outdoor_sensor` identifie celle sélectionnée. +Si l'une des sondes est invalide, le chauffage reste inhibé comme auparavant. +La configuration historique `outdoor_sensor` reste acceptée pour une seule sonde. 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 @@ -67,11 +85,21 @@ cette app, conserve les accents et le symbole °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. +Le plugin transmet les commandes MQTT et les retours chaudière à l'événement +`MQTT_MESSAGE` du namespace `mqtt`, écouté directement par l'app. Le watchdog +AppDaemon marque le climate indisponible après trois minutes sans heartbeat +(vérification toutes les dix secondes). Il ne commande PAS la chaudière. +Il utilise un worker distinct du climate. Il publie `offline` au démarrage et +après reconnexion tant qu'aucun heartbeat récent n'est arrivé. Seul le climate +publie `online`, selon la validité de ses mesures. + +La disponibilité MQTT Discovery exige simultanément la disponibilité de l'app +(`/availability`) et celle de la connexion MQTT (`/connection`). Le plugin +publie `online` sur `/connection` à la connexion, et le broker publie `offline` +via le Last Will si cette connexion est perdue. Ce mécanisme ne garantit pas +une détection immédiate et ne coupe pas le chauffage. Configuration et états +sont retenus ; les commandes utilisateur et chaudière ne doivent jamais l'être. +Une reconnexion MQTT republie la découverte et renouvelle la commande. `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é diff --git a/homeassistant/packages/radiator_heating_mqtt.yaml b/homeassistant/packages/radiator_heating_mqtt.yaml deleted file mode 100644 index 53271ae..0000000 --- a/homeassistant/packages/radiator_heating_mqtt.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# 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 index d9248c3..4fa8780 100644 --- a/tests/test_boilerclimate.py +++ b/tests/test_boilerclimate.py @@ -11,6 +11,7 @@ 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 +from boilerwatchdog import BoilerWatchdog class RegulatorTests(unittest.TestCase): @@ -68,8 +69,12 @@ class ClimateTests(unittest.TestCase): # Test the actual app callbacks without starting an AppDaemon server. app = object.__new__(BoilerClimate) app.base, app.boiler = 'appdaemon/test', 'boiler' + app.mqtt_namespace = 'mqtt' + app.connection_topic = app.base + '/connection' app.mode, app.target = 'heat', 18 app.indoor, app.outdoor = 'sensor.indoor', 'sensor.outdoor' + app.outdoors = (app.outdoor,) + app.selected_outdoor = None app.min_target, app.max_target = 10, 23 app.interval = 60 app.controller = BoilerRegulator() @@ -89,6 +94,39 @@ class ClimateTests(unittest.TestCase): def writes(self, app): return [c for c in app._publish.call_args_list if c.args[0] == 'boiler/bai/SetMode/set'] + def test_mqtt_transport_uses_plugin_namespace(self): + a = self.app() + a.call_service = Mock() + BoilerClimate._publish(a, 'boiler/bai/SetMode/set', setmode(40, True)) + call = a.call_service.call_args + self.assertEqual(call.args, ('mqtt/publish',)) + self.assertEqual(call.kwargs['namespace'], 'mqtt') + self.assertFalse(call.kwargs['retain']) + self.assertEqual(json.loads(call.kwargs['payload']), setmode(40, True)) + + def test_discovery_requires_app_and_connection_online(self): + a = self.app() + a.uid, a.title = 'test_boiler', 'Test boiler' + a._discovery() + config = a._publish.call_args_list[0].args[1] + self.assertNotIn('availability_topic', config) + self.assertEqual(config['availability_mode'], 'all') + self.assertEqual(config['availability'], [{'topic': a.base + '/availability'}, + {'topic': a.connection_topic}]) + + def test_mqtt_reconnect_renews_discovery_and_command(self): + a = self.app() + a._discovery = Mock() + a._mqtt_event('MQTT_MESSAGE', {'state': 'Connected', 'topic': None}, {}) + a._discovery.assert_called_once() + self.assertEqual(len(self.writes(a)), 1) + + def test_mqtt_disconnect_does_not_publish_or_advance_controller(self): + a = self.app() + a._mqtt_event('MQTT_MESSAGE', {'state': 'Disconnected', 'topic': None}, {}) + a._publish.assert_not_called() + self.assertEqual(a.previous_time, 1000) + def test_single_change_and_periodic_refresh_only(self): a = self.app() with patch('boilerclimate.time.monotonic', return_value=1000): @@ -103,6 +141,34 @@ class ClimateTests(unittest.TestCase): for call in self.writes(a): self.assertFalse(call.args[2]) + def test_lowest_outdoor_reading_controls_regulation(self): + a = self.app() + a.target = 23 + a.outdoors = ('sensor.front', 'sensor.back') + readings = {a.indoor: 22.6, 'sensor.front': 30.2, 'sensor.back': 23.7} + a._sensor_value = lambda entity: readings[entity] + a._control() + self.assertEqual(a._state['outdoor'], 23.7) + self.assertEqual(a.selected_outdoor, 'sensor.back') + self.assertTrue(a._state['heating_requested']) + readings['sensor.front'] = 20 + a._sensor_changed('sensor.front', 'all', None, None, {}) + self.assertEqual(a._state['outdoor'], 20) + self.assertEqual(a.selected_outdoor, 'sensor.front') + + def test_invalid_second_outdoor_sensor_still_inhibits_heat(self): + a = self.app() + a.outdoors = ('sensor.front', 'sensor.back') + def reading(entity): + if entity == 'sensor.back': + raise ValueError('sensor.back: unavailable') + return 18 if entity == a.indoor else 5 + a._sensor_value = reading + a._control() + self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1) + self.assertEqual(a._state['reason'], 'invalid_sensor') + self.assertIn('sensor.back', a.sensor_error) + def test_invalid_sensor_explicitly_inhibits_heat(self): a = self.app() a._sensor_value = Mock(side_effect=ValueError('unavailable')) @@ -265,5 +331,45 @@ class ClimateTests(unittest.TestCase): self.assertEqual(a.controller.integral, integral) +class WatchdogTests(unittest.TestCase): + def app(self): + a = object.__new__(BoilerWatchdog) + a.base, a.mqtt_namespace, a.timeout = 'appdaemon/test', 'mqtt', 180 + a.last_heartbeat, a.fault = None, False + a.call_service, a.log = Mock(), Mock() + a.initialize_logger_interface(a, ascii_encode=False) + return a + + def test_missing_heartbeat_publishes_offline_once(self): + a = self.app() + a._check() + a._check() + a.call_service.assert_called_once_with('mqtt/publish', namespace='mqtt', + topic=a.base + '/availability', payload='offline', qos=0, retain=True) + + def test_timeout_and_recovery_do_not_override_sensor_availability(self): + a = self.app() + with patch('boilerwatchdog.time.monotonic', return_value=1000): + a._mqtt_event('', {'topic': a.base + '/heartbeat', 'payload': 'timestamp'}, {}) + with patch('boilerwatchdog.time.monotonic', return_value=1179): + a._check() + a.call_service.assert_not_called() + with patch('boilerwatchdog.time.monotonic', return_value=1180): + a._check() + self.assertTrue(a.fault) + a.call_service.reset_mock() + a._mqtt_event('', {'topic': a.base + '/heartbeat'}, {}) + self.assertFalse(a.fault) + a.call_service.assert_not_called() # The climate alone decides online/offline for sensor faults. + + def test_reconnection_waits_for_new_heartbeat(self): + a = self.app() + a.last_heartbeat = 1000 + a._mqtt_event('', {'topic': None, 'state': 'Connected'}, {}) + self.assertIsNone(a.last_heartbeat) + self.assertTrue(a.fault) + self.assertEqual(a.call_service.call_args.kwargs['payload'], 'offline') + + if __name__ == '__main__': unittest.main()