299 lines
16 KiB
Python
299 lines
16 KiB
Python
"""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()
|