Files
ad_trident/apps/boilerclimate.py
T

299 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""MQTT-discovered climate using AppDaemon's MQTT plugin and HA sensors."""
from datetime import datetime, timezone
import json
from pathlib import Path
import time
import appdaemon.plugins.hass.hassapi as hass
from boilerregulator import BoilerRegulator, finite, setmode
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']
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')
self.min_target, self.max_target = 10.0, 23.0
self.target = finite(self.args.get('target_temperature', 18))
self.mode = self.args.get('initial_mode', 'off')
if self.mode not in ('off', 'heat') or not self.min_target <= self.target <= self.max_target:
raise ValueError('Invalid initial mode or room target')
self.send_commands = self.args.get('send_commands', False) is True
self.controller = BoilerRegulator(**self.args.get('regulation', {}))
self.state_file = Path(str(self.AD.app_dir)) / 'data' / (self.uid + '.json')
self._restore()
self.previous_time = time.monotonic()
self.last_send = 0.0
self.last_payload = None
self.last_command_at = None
self.last_echo_at = None
self.telemetry = {}
self.sensor_error = None
self.last_status_time = 0.0
self._state = None
self.listen_event(self._mqtt_event, 'MQTT_MESSAGE', namespace=self.mqtt_namespace)
self.listen_state(self._sensor_changed, self.indoor, 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()
self.log_info(f'Boiler climate ready: MQTT writes={self.send_commands}, room target={self.target} °C')
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, 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'},
{'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,
temp_step=0.5, min_temp=self.min_target, max_temp=self.max_target,
mode_command_topic=self.base + '/command/mode',
temperature_command_topic=self.base + '/command/temperature',
mode_state_topic=self.base + '/state',
mode_state_template='{{ value_json.mode }}',
temperature_state_topic=self.base + '/state',
temperature_state_template='{{ value_json.target }}',
current_temperature_topic=self.base + '/state',
current_temperature_template='{{ value_json.indoor }}',
action_topic=self.base + '/state', action_template='{{ value_json.action }}',
json_attributes_topic=self.base + '/attributes', retain=False, qos=0)
self._publish('homeassistant/climate/' + self.uid + '/config', config, True)
for key, label in [('flow_requested', 'Température eau demandée'),
('curve', 'Loi d’eau'), ('proportional', 'Correction proportionnelle'),
('integral', 'Correction intégrale')]:
self._publish('homeassistant/sensor/' + self.uid + '/' + key + '/config',
dict(common, name=label, unique_id=self.uid + '_' + key,
state_topic=self.base + '/state',
value_template='{{ value_json.' + key + ' }}',
unit_of_measurement='°C', entity_category='diagnostic'), True)
self._publish('homeassistant/sensor/' + self.uid + '/reason/config',
dict(device=device, name='État de la régulation', unique_id=self.uid + '_reason',
state_topic=self.base + '/state', value_template='{{ value_json.reason }}',
json_attributes_topic=self.base + '/state',
json_attributes_template='{{ {"sensor_error": value_json.sensor_error} | tojson }}',
expire_after=180, entity_category='diagnostic'), True)
self._publish('homeassistant/sensor/' + self.uid + '/heartbeat/config',
dict(name='Heartbeat', device=device, unique_id=self.uid + '_heartbeat',
state_topic=self.base + '/heartbeat', expire_after=180,
entity_category='diagnostic', device_class='timestamp'), True)
def _restore(self):
try:
data = json.loads(self.state_file.read_text())
if not isinstance(data, dict) or data.get('mode') not in ('heat', 'off'):
raise ValueError('Invalid saved climate mode')
self.mode = data['mode']
target = finite(data['target'])
if not self.min_target <= target <= self.max_target:
raise ValueError('Saved room target out of bounds')
self.target = target
except FileNotFoundError:
pass # First start uses the configured initial mode and target.
except (OSError, ValueError, KeyError, TypeError) as exc:
self.mode = 'off'
self.log_warning(f'Could not restore climate state; heating remains off: {exc}')
def _save(self):
self.state_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self.state_file.with_suffix('.tmp')
tmp.write_text(json.dumps(dict(mode=self.mode, target=self.target)))
tmp.replace(self.state_file)
def _save_safely(self):
try:
self._save()
except OSError as exc:
self.log_error(f'Could not save climate state; boiler control continues: {exc}')
def _sensor_value(self, entity):
data = self.get_state(entity, attribute='all')
if not isinstance(data, dict):
raise ValueError(f'{entity}: sensor missing')
attributes = data.get('attributes', {})
name = attributes.get('friendly_name')
label = f'{entity} ({name})' if name else entity
if attributes.get('unit_of_measurement') != '°C':
raise ValueError(f'{label}: expected Celsius unit')
try:
value = finite(data.get('state'))
except (ValueError, TypeError):
raise ValueError(f"{label}: invalid state {data.get('state')!r}") from None
if not -40 <= value <= 60:
raise ValueError(f'{label}: implausible temperature {value} °C')
return value
def _sensor_changed(self, entity, attribute, old, new, kwargs):
self._control()
def _tick(self, kwargs=None):
self._control()
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()
return
if topic == self.base + '/command/mode':
if payload not in ('off', 'heat'):
self.log_warning('Rejected invalid climate mode')
return
self.mode = payload
self.controller.reset()
self._control(force=True)
self._save_safely()
elif topic == self.base + '/command/temperature':
try:
target = finite(payload)
if not self.min_target <= target <= self.max_target:
raise ValueError('Target out of bounds')
except (ValueError, TypeError):
self.log_warning('Rejected invalid room target')
return
self.target = target
self.controller.reset()
self._control(force=True)
self._save_safely()
elif topic and topic.startswith(self.boiler + '/bai/'):
try:
value = payload if isinstance(payload, dict) else json.loads(payload)
if not isinstance(value, dict):
return
except (ValueError, TypeError):
return
name = topic.rsplit('/', 1)[-1]
self.telemetry[name] = value
if name == 'Statenumber':
self.last_status_time = time.monotonic()
if name == 'SetMode' and self.last_payload is not None:
if all(value.get(k) == v for k, v in self.last_payload.items()):
self.last_echo_at = datetime.now(timezone.utc).isoformat()
# Telemetry is diagnostic only: it never renews a boiler command.
if name == 'Statenumber':
self._publish_state()
self._attributes()
def _publish_state(self, now=None):
if getattr(self, '_state', None) is None:
return
now = time.monotonic() if now is None else now
status = self.telemetry.get('Statenumber', {}).get('value')
heating = status in (4, '4') and now - self.last_status_time < 180
self._state['action'] = 'off' if self.mode == 'off' else ('heating' if heating else 'idle')
self._publish(self.base + '/state', self._state.copy(), True)
def _attributes(self):
self._publish(self.base + '/attributes', dict(
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,
last_command=self.last_payload, boiler_telemetry=self.telemetry,
note='C6 echo confirms transmission, not effective boiler setpoint'), True)
def _control(self, force=False):
now = time.monotonic()
dt = min(max(0, now - self.previous_time), self.interval * 2)
self.previous_time = now
indoor = outdoor = None
result = None
readings, errors = {}, []
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 = 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 sensors)
previous = getattr(self, '_sensor_fault_signature', None)
if not valid and signature != previous:
self.log_error(f'Heating inhibited: sensor fault: {sensor_error}')
elif valid and previous is not None and previous != signature:
self.log_info('Temperature sensors recovered; regulation can resume. '
f"Previous fault: {getattr(self, 'sensor_error', None)}")
self._sensor_fault_signature = signature
self.sensor_error = sensor_error
if self.mode == 'off' or not valid:
self.controller.reset()
reason = 'off' if self.mode == 'off' else 'invalid_sensor'
enabled, flow = False, 0
else:
result = self.controller.update(self.target, indoor, outdoor, dt)
enabled, flow, reason = result.enabled, result.flow, result.reason
command = setmode(flow, enabled)
if self.send_commands and (force or command != self.last_payload or now - self.last_send >= self.interval):
self._publish(self.boiler + '/bai/SetMode/set', command, False)
if command != self.last_payload:
self.log_info(f"Heating request: enabled={enabled}, flow={command['flowtempdesired']} °C, reason={reason}")
self.last_payload, self.last_send = command, now
self.last_command_at = datetime.now(timezone.utc).isoformat()
self._state = dict(mode=self.mode, target=self.target, indoor=indoor,
outdoor=outdoor, reason=reason, sensor_error=self.sensor_error,
heating_requested=enabled, flow_requested=flow if enabled else 0,
curve=round(result.curve, 2) if result else 0,
proportional=round(result.proportional, 2) if result else 0,
integral=round(result.integral, 3) if result else 0)
self._publish_state(now)
self._attributes()
self._publish(self.base + '/availability', 'online' if valid else 'offline', True)
self._publish(self.base + '/heartbeat', datetime.now(timezone.utc).isoformat())
# Request actual status, without writing or renewing SetMode.
self._publish(self.boiler + '/bai/Statenumber/get', {})
self._publish(self.boiler + '/bai/FlowTemp/get', {})
def terminate(self):
# An orderly shutdown explicitly inhibits space heating; a crash relies
# on boiler command expiration. No retained actuator command, ever.
if not hasattr(self, 'base'):
return
try:
if self.send_commands:
self._publish(self.boiler + '/bai/SetMode/set', setmode(0, False))
except Exception as exc:
self.log_error(f'Could not publish shutdown; boiler expiration remains the fallback: {exc}')
try:
self._publish(self.base + '/availability', 'offline', True)
except Exception as exc:
self.log_warning(f'Could not publish offline availability: {exc}')
self._save_safely()