first version of the boiler controlled by ebusd

This commit is contained in:
Pierre committed 2026-10-10 15:27:31 +02:00
1 parent c21291e9dd
commit a2f40f81ba
7 files changed
+819 -2

No files matched your search

+278
View File
@@ -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()
+86
View File
@@ -0,0 +1,86 @@
"""Weather compensation and room PI correction, expressed in water degrees."""
from dataclasses import dataclass
import math
def finite(value):
value = float(value)
if not math.isfinite(value):
raise ValueError('Temperature must be finite')
return value
def clamp(value, low, high):
return max(low, min(high, value))
@dataclass
class Regulation:
flow: float
enabled: bool
curve: float
proportional: float
integral: float
reason: str
class BoilerRegulator:
def __init__(self, *, slope=1.5, offset=10, kp=5, ki=0.2,
integral_limit=8, correction_limit=12, min_flow=30,
max_flow=50, overheat=0.5, restart_margin=0.1):
self.slope, self.offset = finite(slope), finite(offset)
self.kp, self.ki = finite(kp), finite(ki)
self.integral_limit, self.correction_limit = finite(integral_limit), finite(correction_limit)
self.min_flow, self.max_flow = finite(min_flow), finite(max_flow)
self.overheat, self.restart_margin = finite(overheat), finite(restart_margin)
if not (0 <= self.min_flow < self.max_flow <= 80):
raise ValueError('Require 0 <= min_flow < max_flow <= 80')
if min(self.slope, self.kp, self.ki, self.integral_limit, self.correction_limit) < 0:
raise ValueError('Gains and limits must be non-negative')
if not 0 <= self.restart_margin < self.overheat:
raise ValueError('Require 0 <= restart_margin < overheat')
self.integral = 0.0
self.enabled = False
def reset(self):
self.integral = 0.0
self.enabled = False
def update(self, target, indoor, outdoor, dt):
target, indoor, outdoor, dt = map(finite, (target, indoor, outdoor, dt))
error = target - indoor
curve = target + self.slope * (target - outdoor) + self.offset
proportional = self.kp * error
# ki is water °C / (room °C * hour). No accumulated downtime.
candidate = clamp(self.integral + self.ki * error * max(0, dt) / 3600,
-self.integral_limit, self.integral_limit)
raw = curve + clamp(proportional + candidate, -self.correction_limit, self.correction_limit)
# Anti-windup for BOTH the water bounds and the correction bound.
saturated_high = raw >= self.max_flow or proportional + candidate >= self.correction_limit
saturated_low = raw <= self.min_flow or proportional + candidate <= -self.correction_limit
if not ((saturated_high and error > 0) or (saturated_low and error < 0)):
self.integral = candidate
raw = curve + clamp(proportional + self.integral, -self.correction_limit, self.correction_limit)
if indoor >= target + self.overheat:
self.enabled = False
reason = 'room_overheat'
elif raw <= self.min_flow - 1:
self.enabled = False
reason = 'low_demand'
elif not self.enabled:
if indoor <= target + self.restart_margin and raw >= self.min_flow + 1:
self.enabled = True
reason = 'regulating' if self.enabled else 'waiting_for_demand'
else:
reason = 'regulating'
flow = round(clamp(raw, self.min_flow, self.max_flow) * 2) / 2
return Regulation(flow, self.enabled, curve, proportional, self.integral, reason)
def setmode(flow, enabled):
"""Full C6 SetMode payload. No new domestic hot-water setpoint."""
return dict(hcmode='auto', flowtempdesired=finite(flow) if enabled else 0,
hwctempdesired=None, hwcflowtempdesired=None,
disablehc=0 if enabled else 1, disablehwctapping=0,
disablehwcload=0, remotecontrolhcpump=0,
releasebackup=0, releasecooling=0)
+31 -1
View File
@@ -216,4 +216,34 @@ guestroom_heatpump:
sensor: input_select.guestroom_heatpump_override
reset_events:
- good_morning
- good_bye
- good_bye
radiator_heating:
module: boilerclimate
class: BoilerClimate
display_name: Chauffage radiateurs
unique_id: appdaemon_radiator_heating
indoor_sensor: sensor.corridor_temperature
outdoor_sensor: sensor.ext_front_temperature
target_temperature: 18
initial_mode: heat
# Radiator heating enabled with the room-thermostat bridge CLOSED.
# false keeps the climate/diagnostics but suppresses SetMode writes.
send_commands: true
command_interval: 60
mqtt_topic: appdaemon/radiator_heating
boiler_topic: themaplus-condens
# Starting values, to tune from observations with radiator circulation enabled.
regulation:
slope: 1.5
offset: 10
kp: 5
ki: 0.2 # water °C / (room °C * hour)
integral_limit: 8
correction_limit: 12
min_flow: 30
max_flow: 50
overheat: 0.5
restart_margin: 0.1