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
+1
-1
Submodule apps/ad_toolbox updated: cf8839db6a...5ae930c710.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user