the boiler app now use mqtt instead of relying on an automation on ha side
This commit is contained in:
1 parent
a2f40f81ba
commit
6447d754ca
8 files changed
+261
-77
No files matched your search
@@ -4,3 +4,4 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
logs/
|
logs/
|
||||||
apps/data/
|
apps/data/
|
||||||
|
secrets.yaml
|
||||||
@@ -13,6 +13,29 @@ appdaemon:
|
|||||||
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiZThhYWI2ZTlkNmQ0NWU2YTk1ODU5OWJjOWM3MWJkYiIsImlhdCI6MTc3NjEwOTc0NCwiZXhwIjoyMDkxNDY5NzQ0fQ.BorkjFjWlWCZqnUa9-NMUxGsDiupDoRZ3cEgsmeSofM
|
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJiZThhYWI2ZTlkNmQ0NWU2YTk1ODU5OWJjOWM3MWJkYiIsImlhdCI6MTc3NjEwOTc0NCwiZXhwIjoyMDkxNDY5NzQ0fQ.BorkjFjWlWCZqnUa9-NMUxGsDiupDoRZ3cEgsmeSofM
|
||||||
cert_verify: True
|
cert_verify: True
|
||||||
ws_timeout: 00:10:00
|
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:
|
http:
|
||||||
url: http://0.0.0.0:5050
|
url: http://0.0.0.0:5050
|
||||||
admin:
|
admin:
|
||||||
|
|||||||
+34
-14
@@ -1,8 +1,4 @@
|
|||||||
"""MQTT-discovered boiler climate; MQTT transport is Home Assistant's service.
|
"""MQTT-discovered climate using AppDaemon's MQTT plugin and HA sensors."""
|
||||||
|
|
||||||
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
|
from datetime import datetime, timezone
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,12 +12,22 @@ from logger_interface import LoggerInterface
|
|||||||
class BoilerClimate(hass.Hass, LoggerInterface):
|
class BoilerClimate(hass.Hass, LoggerInterface):
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
self.initialize_logger_interface(self.get_ad_api(), ascii_encode=False)
|
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.base = self.args.get('mqtt_topic', 'appdaemon/radiator_heating').rstrip('/')
|
||||||
self.boiler = self.args.get('boiler_topic', 'themaplus-condens').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.uid = self.args.get('unique_id', 'appdaemon_radiator_heating')
|
||||||
self.title = self.args.get('display_name', 'Chauffage radiateurs')
|
self.title = self.args.get('display_name', 'Chauffage radiateurs')
|
||||||
self.indoor = self.args['indoor_sensor']
|
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))
|
self.interval = finite(self.args.get('command_interval', 60))
|
||||||
if not 10 <= self.interval <= 300:
|
if not 10 <= self.interval <= 300:
|
||||||
raise ValueError('command_interval must be between 10 and 300 seconds')
|
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.sensor_error = None
|
||||||
self.last_status_time = 0.0
|
self.last_status_time = 0.0
|
||||||
self._state = None
|
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.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._discovery()
|
||||||
self.run_every(self._tick, 'now', self.interval)
|
self.run_every(self._tick, 'now', self.interval)
|
||||||
self._control()
|
self._control()
|
||||||
@@ -54,12 +61,15 @@ class BoilerClimate(hass.Hass, LoggerInterface):
|
|||||||
def _publish(self, topic, payload, retain=False):
|
def _publish(self, topic, payload, retain=False):
|
||||||
if not isinstance(payload, str):
|
if not isinstance(payload, str):
|
||||||
payload = json.dumps(payload, allow_nan=False, ensure_ascii=False)
|
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):
|
def _discovery(self):
|
||||||
device = dict(identifiers=[self.uid], name=self.title,
|
device = dict(identifiers=[self.uid], name=self.title,
|
||||||
manufacturer='AppDaemon', model='Weather compensation + room PI')
|
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,
|
config = dict(common, name=None, unique_id=self.uid,
|
||||||
default_entity_id='climate.radiator_heating',
|
default_entity_id='climate.radiator_heating',
|
||||||
modes=['off', 'heat'], temperature_unit='C', precision=0.1,
|
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):
|
def _mqtt_event(self, event, data, kwargs):
|
||||||
topic, payload = data.get('topic'), data.get('payload', '')
|
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':
|
if topic == 'homeassistant/status' and payload == 'online':
|
||||||
self._discovery()
|
self._discovery()
|
||||||
self._control()
|
self._control()
|
||||||
@@ -201,7 +216,8 @@ class BoilerClimate(hass.Hass, LoggerInterface):
|
|||||||
|
|
||||||
def _attributes(self):
|
def _attributes(self):
|
||||||
self._publish(self.base + '/attributes', dict(
|
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,
|
commands_enabled=self.send_commands,
|
||||||
sensor_error=getattr(self, 'sensor_error', None),
|
sensor_error=getattr(self, 'sensor_error', None),
|
||||||
last_command_at=self.last_command_at, last_c6_echo_at=self.last_echo_at,
|
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
|
indoor = outdoor = None
|
||||||
result = None
|
result = None
|
||||||
readings, errors = {}, []
|
readings, errors = {}, []
|
||||||
for entity in (self.indoor, self.outdoor):
|
sensors = (self.indoor, *self.outdoors)
|
||||||
|
for entity in sensors:
|
||||||
try:
|
try:
|
||||||
readings[entity] = self._sensor_value(entity)
|
readings[entity] = self._sensor_value(entity)
|
||||||
except (ValueError, TypeError, KeyError) as exc:
|
except (ValueError, TypeError, KeyError) as exc:
|
||||||
errors.append(str(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
|
valid = not errors
|
||||||
sensor_error = '; '.join(errors) if errors else None
|
sensor_error = '; '.join(errors) if errors else None
|
||||||
# Log once per set of failing sensors, not every minute as age increases.
|
# 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)
|
previous = getattr(self, '_sensor_fault_signature', None)
|
||||||
if not valid and signature != previous:
|
if not valid and signature != previous:
|
||||||
self.log_error(f'Heating inhibited: sensor fault: {sensor_error}')
|
self.log_error(f'Heating inhibited: sensor fault: {sensor_error}')
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -224,7 +224,9 @@ radiator_heating:
|
|||||||
display_name: Chauffage radiateurs
|
display_name: Chauffage radiateurs
|
||||||
unique_id: appdaemon_radiator_heating
|
unique_id: appdaemon_radiator_heating
|
||||||
indoor_sensor: sensor.corridor_temperature
|
indoor_sensor: sensor.corridor_temperature
|
||||||
outdoor_sensor: sensor.ext_front_temperature
|
outdoor_sensors:
|
||||||
|
- sensor.ext_front_temperature
|
||||||
|
- sensor.ext_back_temperature
|
||||||
target_temperature: 18
|
target_temperature: 18
|
||||||
initial_mode: heat
|
initial_mode: heat
|
||||||
|
|
||||||
@@ -233,6 +235,8 @@ radiator_heating:
|
|||||||
send_commands: true
|
send_commands: true
|
||||||
command_interval: 60
|
command_interval: 60
|
||||||
mqtt_topic: appdaemon/radiator_heating
|
mqtt_topic: appdaemon/radiator_heating
|
||||||
|
mqtt_namespace: mqtt
|
||||||
|
mqtt_connection_topic: appdaemon/radiator_heating/connection
|
||||||
boiler_topic: themaplus-condens
|
boiler_topic: themaplus-condens
|
||||||
|
|
||||||
# Starting values, to tune from observations with radiator circulation enabled.
|
# Starting values, to tune from observations with radiator circulation enabled.
|
||||||
@@ -247,3 +251,10 @@ radiator_heating:
|
|||||||
max_flow: 50
|
max_flow: 50
|
||||||
overheat: 0.5
|
overheat: 0.5
|
||||||
restart_margin: 0.1
|
restart_margin: 0.1
|
||||||
|
|
||||||
|
radiator_heating_watchdog:
|
||||||
|
module: boilerwatchdog
|
||||||
|
class: BoilerWatchdog
|
||||||
|
mqtt_namespace: mqtt
|
||||||
|
mqtt_topic: appdaemon/radiator_heating
|
||||||
|
heartbeat_timeout: 180
|
||||||
+43
-15
@@ -1,23 +1,36 @@
|
|||||||
# Chauffage radiateurs — AppDaemon et C6
|
# Chauffage radiateurs — AppDaemon et C6
|
||||||
|
|
||||||
L'app `radiator_heating` expose `climate.radiator_heating` par MQTT Discovery.
|
L'app `radiator_heating` expose `climate.radiator_heating` par MQTT Discovery.
|
||||||
Les publications utilisent le service `mqtt.publish` de Home Assistant, comme
|
La version MQTT directe utilise le plugin MQTT d'AppDaemon pour les publications
|
||||||
l'ancienne implémentation de SmartObject. Aucun identifiant MQTT supplémentaire
|
et les abonnements. Le plugin Home Assistant sert à lire les thermomètres.
|
||||||
ni plugin MQTT AppDaemon n'est nécessaire.
|
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
|
## Fichiers
|
||||||
|
|
||||||
- `apps/boilerclimate.py` : MQTT Discovery, consigne, mode, mesures et commandes.
|
- `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/boilerregulator.py` : calcul indépendant de la température d'eau.
|
||||||
- `apps/global/heating.yaml`, bloc `radiator_heating` : paramètres actifs de l'app.
|
- `apps/global/heating.yaml`, blocs `radiator_heating` et
|
||||||
- `homeassistant/packages/radiator_heating_mqtt.yaml` : copie des deux
|
`radiator_heating_watchdog` : paramètres actifs des deux apps.
|
||||||
automatisations installées via l'API de configuration de Home Assistant.
|
- `appdaemon.yaml`, plugin `MQTT` : connexion broker, abonnements et Last Will.
|
||||||
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
|
## 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
|
courbe = S + pente × (S − Te) + décalage
|
||||||
correction P = kp × (S − Ti)
|
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,
|
Les gains sont des valeurs initiales à ajuster après les essais de circulation,
|
||||||
pas des coefficients identifiés pour cette maison.
|
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
|
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
|
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
|
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`.
|
- État et diagnostics : `appdaemon/radiator_heating/state` et `/attributes`.
|
||||||
- Chaudière : `themaplus-condens/bai/SetMode/set`.
|
- Chaudière : `themaplus-condens/bai/SetMode/set`.
|
||||||
|
|
||||||
L'automatisation de retour transmet les commandes MQTT et les retours chaudière
|
Le plugin transmet les commandes MQTT et les retours chaudière à l'événement
|
||||||
à l'événement `APPDAEMON_BOILER_MQTT`, écouté par l'app. Le watchdog marque le
|
`MQTT_MESSAGE` du namespace `mqtt`, écouté directement par l'app. Le watchdog
|
||||||
climate indisponible après trois minutes sans heartbeat. Il ne commande PAS
|
AppDaemon marque le climate indisponible après trois minutes sans heartbeat
|
||||||
la chaudière : il signale l'arrêt de l'app. Configuration et états sont retenus ;
|
(vérification toutes les dix secondes). Il ne commande PAS la chaudière.
|
||||||
les commandes utilisateur et chaudière ne doivent jamais être retenues.
|
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.
|
`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é
|
Les retours de télémétrie ne renouvellent pas la commande. Un arrêt ordonné
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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'))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox'))
|
||||||
from boilerregulator import BoilerRegulator, setmode
|
from boilerregulator import BoilerRegulator, setmode
|
||||||
from boilerclimate import BoilerClimate
|
from boilerclimate import BoilerClimate
|
||||||
|
from boilerwatchdog import BoilerWatchdog
|
||||||
|
|
||||||
|
|
||||||
class RegulatorTests(unittest.TestCase):
|
class RegulatorTests(unittest.TestCase):
|
||||||
@@ -68,8 +69,12 @@ class ClimateTests(unittest.TestCase):
|
|||||||
# Test the actual app callbacks without starting an AppDaemon server.
|
# Test the actual app callbacks without starting an AppDaemon server.
|
||||||
app = object.__new__(BoilerClimate)
|
app = object.__new__(BoilerClimate)
|
||||||
app.base, app.boiler = 'appdaemon/test', 'boiler'
|
app.base, app.boiler = 'appdaemon/test', 'boiler'
|
||||||
|
app.mqtt_namespace = 'mqtt'
|
||||||
|
app.connection_topic = app.base + '/connection'
|
||||||
app.mode, app.target = 'heat', 18
|
app.mode, app.target = 'heat', 18
|
||||||
app.indoor, app.outdoor = 'sensor.indoor', 'sensor.outdoor'
|
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.min_target, app.max_target = 10, 23
|
||||||
app.interval = 60
|
app.interval = 60
|
||||||
app.controller = BoilerRegulator()
|
app.controller = BoilerRegulator()
|
||||||
@@ -89,6 +94,39 @@ class ClimateTests(unittest.TestCase):
|
|||||||
def writes(self, app):
|
def writes(self, app):
|
||||||
return [c for c in app._publish.call_args_list if c.args[0] == 'boiler/bai/SetMode/set']
|
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):
|
def test_single_change_and_periodic_refresh_only(self):
|
||||||
a = self.app()
|
a = self.app()
|
||||||
with patch('boilerclimate.time.monotonic', return_value=1000):
|
with patch('boilerclimate.time.monotonic', return_value=1000):
|
||||||
@@ -103,6 +141,34 @@ class ClimateTests(unittest.TestCase):
|
|||||||
for call in self.writes(a):
|
for call in self.writes(a):
|
||||||
self.assertFalse(call.args[2])
|
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):
|
def test_invalid_sensor_explicitly_inhibits_heat(self):
|
||||||
a = self.app()
|
a = self.app()
|
||||||
a._sensor_value = Mock(side_effect=ValueError('unavailable'))
|
a._sensor_value = Mock(side_effect=ValueError('unavailable'))
|
||||||
@@ -265,5 +331,45 @@ class ClimateTests(unittest.TestCase):
|
|||||||
self.assertEqual(a.controller.integral, integral)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user