"""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()