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
+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
|
||||
Reference in new issue
Block a user