"""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. """ 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.base = self.args.get('mqtt_topic', 'appdaemon/radiator_heating').rstrip('/') self.boiler = self.args.get('boiler_topic', 'themaplus-condens').rstrip('/') 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'] 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, 'APPDAEMON_BOILER_MQTT') self.listen_state(self._sensor_changed, self.indoor, attribute='all') self.listen_state(self._sensor_changed, self.outdoor, 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) 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') 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 == '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_sensor=self.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 = {}, [] for entity in (self.indoor, self.outdoor): 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) 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)) 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()