first version of the boiler controlled by ebusd
This commit is contained in:
1 parent
c21291e9dd
commit
a2f40f81ba
7 files changed
+819
-2
No files matched your search
@@ -0,0 +1,278 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user