the boiler app now use mqtt instead of relying on an automation on ha side

This commit is contained in:
Pierre committed 2026-10-10 16:19:37 +02:00
1 parent a2f40f81ba
commit 6447d754ca
8 files changed
+261 -77

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ __pycache__/
*.pyc *.pyc
logs/ logs/
apps/data/ apps/data/
secrets.yaml
+23
View File
@@ -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
View File
@@ -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}')
+42
View File
@@ -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
+12 -1
View File
@@ -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
View File
@@ -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
+106
View File
@@ -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()