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
|
||||
logs/
|
||||
apps/data/
|
||||
secrets.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:
|
||||
|
||||
+34
-14
@@ -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}')
|
||||
|
||||
@@ -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
|
||||
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
|
||||
+43
-15
@@ -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é
|
||||
|
||||
@@ -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'))
|
||||
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()
|
||||
Reference in new issue
Block a user