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
|
||||
@@ -0,0 +1,107 @@
|
||||
# Chauffage radiateurs — AppDaemon et C6
|
||||
|
||||
L'app `radiator_heating` expose `climate.radiator_heating` par MQTT Discovery.
|
||||
Les publications utilisent le service `mqtt.publish` de Home Assistant, comme
|
||||
l'ancienne implémentation de SmartObject. Aucun identifiant MQTT supplémentaire
|
||||
ni plugin MQTT AppDaemon n'est nécessaire.
|
||||
|
||||
## Fichiers
|
||||
|
||||
- `apps/boilerclimate.py` : MQTT Discovery, consigne, mode, mesures et commandes.
|
||||
- `apps/boilerregulator.py` : calcul indépendant de la température d'eau.
|
||||
- `apps/global/heating.yaml`, bloc `radiator_heating` : paramètres actifs de l'app.
|
||||
- `homeassistant/packages/radiator_heating_mqtt.yaml` : copie des deux
|
||||
automatisations installées via l'API de configuration de Home Assistant.
|
||||
Ne pas inclure aussi cette copie comme package : les automatisations existent
|
||||
déjà dans Home Assistant. Leur modification dans cette copie ne les redéploie pas.
|
||||
|
||||
## Régulation
|
||||
|
||||
Avec S la consigne ambiante, Ti la température du couloir et Te celle de devant :
|
||||
|
||||
courbe = S + pente × (S − Te) + décalage
|
||||
correction P = kp × (S − Ti)
|
||||
correction I += ki × (S − Ti) × durée_en_heures
|
||||
demande = courbe + correction bornée(P + I)
|
||||
|
||||
La sortie est arrondie au demi-degré et limitée entre 30 et 50 °C initialement.
|
||||
`ki` est exprimé en degrés d'eau par degré d'écart intérieur et par heure.
|
||||
Les bornes de sortie et de correction disposent d'une protection anti-windup.
|
||||
L'intégrale est remise à zéro à l'arrêt, en cas de mesure invalide, au changement
|
||||
manuel de consigne et au redémarrage. Consigne et mode sont persistés dans
|
||||
`apps/data/appdaemon_radiator_heating.json`, exclu de Git.
|
||||
Une sauvegarde absente au premier lancement utilise la configuration initiale.
|
||||
Une sauvegarde illisible ou invalide impose le mode `off` avec un avertissement.
|
||||
Une erreur d'écriture est journalisée sans bloquer la commande chaudière,
|
||||
notamment l'arrêt demandé par l'utilisateur ou l'arrêt ordonné de l'app.
|
||||
|
||||
À l'équilibre, la chauffe peut rester demandée même lorsque Ti = S.
|
||||
L'app inhibe le chauffage si Ti >= S + 0,5 °C, ou si la demande calculée descend
|
||||
sous la température minimale utile (avec une hystérésis de 1 °C d'eau).
|
||||
Après arrêt, le redémarrage exige Ti <= S + 0,1 °C et une demande suffisante.
|
||||
Les gains sont des valeurs initiales à ajuster après les essais de circulation,
|
||||
pas des coefficients identifiés pour cette maison.
|
||||
|
||||
Les deux capteurs doivent fournir des valeurs finies en °C, entre −40 et 60 °C.
|
||||
Une température inchangée, même ancienne, reste valide : ces sondes peuvent
|
||||
ne publier qu'en cas de changement. Aucun délai sur l'âge de la mesure n'est
|
||||
appliqué. Les états indisponibles/inconnus, les valeurs invalides et les unités
|
||||
incorrectes provoquent toujours l'inhibition. Une sonde déconnectée conservant
|
||||
une valeur plausible n'est donc pas détectée par ce contrôle.
|
||||
|
||||
Un défaut publie un log ERROR avec l'identifiant, le nom du capteur et sa cause
|
||||
(état invalide, unité ou valeur incohérente). Le log est émis
|
||||
à l'apparition du défaut ou au changement des capteurs concernés, sans répétition
|
||||
chaque minute. Le détail actualisé reste disponible dans l'attribut `sensor_error`
|
||||
du capteur « État de la régulation », même quand le climate est indisponible.
|
||||
Un log INFO signale le rétablissement. L'app utilise le pipeline standard
|
||||
`LoggerInterface` (`log_info`, `log_warning`, `log_error`) ; les erreurs sont
|
||||
également envoyées dans `error_log`. L'option `ascii_encode=False`, propre à
|
||||
cette app, conserve les accents et le symbole °C.
|
||||
|
||||
## MQTT et disponibilité
|
||||
|
||||
- Configuration : `homeassistant/climate/appdaemon_radiator_heating/config`.
|
||||
- Commandes utilisateur : `appdaemon/radiator_heating/command/mode` (`heat`/`off`)
|
||||
et `/command/temperature` (consigne ambiante entre 10 et 23 °C).
|
||||
- État et diagnostics : `appdaemon/radiator_heating/state` et `/attributes`.
|
||||
- Chaudière : `themaplus-condens/bai/SetMode/set`.
|
||||
|
||||
L'automatisation de retour transmet les commandes MQTT et les retours chaudière
|
||||
à l'événement `APPDAEMON_BOILER_MQTT`, écouté par l'app. Le watchdog marque le
|
||||
climate indisponible après trois minutes sans heartbeat. Il ne commande PAS
|
||||
la chaudière : il signale l'arrêt de l'app. Configuration et états sont retenus ;
|
||||
les commandes utilisateur et chaudière ne doivent jamais être retenues.
|
||||
|
||||
`SetMode` est renouvelé chaque minute et lors d'un changement de demande.
|
||||
Les retours de télémétrie ne renouvellent pas la commande. Un arrêt ordonné
|
||||
publie une inhibition du chauffage ; un crash repose sur l'expiration observée
|
||||
de la commande chaudière. Les champs de consigne ECS sont `null` et les bits
|
||||
d'inhibition sanitaire restent à zéro.
|
||||
|
||||
`heating_requested` désigne la demande de l'app. L'action `heating` du climate
|
||||
n'est annoncée qu'avec un état chaudière S.04 récent et est actualisée dès
|
||||
réception du statut, sans recalcul ni renouvellement de commande ; une chauffe sanitaire
|
||||
n'est pas comptée comme chauffage des radiateurs. Sans télémétrie récente,
|
||||
`idle` ne constitue pas une preuve d'absence de flamme : consulter aussi la
|
||||
chaudière. L'écho SetMode confirme une transmission C6, pas la consigne effective.
|
||||
|
||||
## Essais actuels
|
||||
|
||||
Les écritures sont autorisées (`send_commands: true`), pont thermostat fermé.
|
||||
Le climate démarre en mode `heat`, consigne 18 °C, sauf état utilisateur persisté.
|
||||
Un couloir plus chaud que la consigne entraîne normalement une inhibition.
|
||||
Le retour au réglage local de 10 °C après environ 15 minutes sans renouvellement
|
||||
a été observé pont ouvert. L'arrêt réel après perte de communication reste à
|
||||
vérifier avec circulation autorisée ; ce délai n'est pas une coupure immédiate.
|
||||
|
||||
Un premier essai pont fermé avec une consigne ambiante de 23 °C a produit une
|
||||
demande d'eau à 46 °C et un démarrage de la chauffe. Les réglages restent à
|
||||
ajuster selon l'évolution réelle des températures. Ne pas modifier les mesures
|
||||
réelles pour simuler la chauffe.
|
||||
`send_commands: false` permet de conserver l'interface et les calculs sans écrire
|
||||
SetMode. Les lectures de télémétrie continuent.
|
||||
|
||||
## Vérification
|
||||
|
||||
python -m unittest discover -s tests -p test_boilerclimate.py -v
|
||||
@@ -0,0 +1,47 @@
|
||||
# Installed through HA's automation configuration API. This is the reviewable
|
||||
# source copy; do not also include it as a package if the automations exist.
|
||||
automation:
|
||||
- id: appdaemon_radiator_heating_bridge
|
||||
alias: AppDaemon - Chauffage radiateurs - Retour MQTT
|
||||
description: Transmet les commandes du climate et les retours C6 à AppDaemon.
|
||||
triggers:
|
||||
- trigger: mqtt
|
||||
topic: appdaemon/radiator_heating/command/+
|
||||
- trigger: mqtt
|
||||
topic: themaplus-condens/bai/SetMode
|
||||
- trigger: mqtt
|
||||
topic: themaplus-condens/bai/Statenumber
|
||||
- trigger: mqtt
|
||||
topic: themaplus-condens/bai/FlowTemp
|
||||
- trigger: mqtt
|
||||
topic: homeassistant/status
|
||||
actions:
|
||||
- event: APPDAEMON_BOILER_MQTT
|
||||
event_data:
|
||||
topic: '{{ trigger.topic }}'
|
||||
payload: '{{ trigger.payload }}'
|
||||
mode: queued
|
||||
max: 50
|
||||
- id: appdaemon_radiator_heating_watchdog
|
||||
alias: AppDaemon - Chauffage radiateurs - Disponibilité
|
||||
description: Marque le climate indisponible après trois minutes sans heartbeat.
|
||||
triggers:
|
||||
- trigger: mqtt
|
||||
topic: appdaemon/radiator_heating/heartbeat
|
||||
id: heartbeat
|
||||
- trigger: homeassistant
|
||||
event: start
|
||||
id: startup
|
||||
actions:
|
||||
- if:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.id == 'heartbeat' }}"
|
||||
then:
|
||||
- delay:
|
||||
minutes: 3
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: appdaemon/radiator_heating/availability
|
||||
payload: offline
|
||||
retain: true
|
||||
mode: restart
|
||||
@@ -0,0 +1,269 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps'))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps' / 'ad_toolbox'))
|
||||
from boilerregulator import BoilerRegulator, setmode
|
||||
from boilerclimate import BoilerClimate
|
||||
|
||||
|
||||
class RegulatorTests(unittest.TestCase):
|
||||
def test_equilibrium_retains_heat_at_target(self):
|
||||
r = BoilerRegulator()
|
||||
v = r.update(18, 18, 5, 60)
|
||||
self.assertTrue(v.enabled)
|
||||
self.assertEqual(v.flow, 47.5)
|
||||
|
||||
def test_colder_outdoors_and_room_increase_water_request(self):
|
||||
mild = BoilerRegulator().update(18, 18, 10, 0)
|
||||
cold = BoilerRegulator().update(18, 18, 5, 0)
|
||||
cold_room = BoilerRegulator().update(18, 17, 10, 0)
|
||||
self.assertGreater(cold.flow, mild.flow)
|
||||
self.assertGreater(cold_room.flow, mild.flow)
|
||||
|
||||
def test_persistent_error_integrates_in_water_degrees(self):
|
||||
r = BoilerRegulator(max_flow=65)
|
||||
v = r.update(18, 17.5, 10, 3600)
|
||||
self.assertAlmostEqual(v.integral, 0.1)
|
||||
|
||||
def test_upper_saturation_does_not_wind_up(self):
|
||||
r = BoilerRegulator()
|
||||
for _ in range(1000):
|
||||
v = r.update(18, 12, -10, 60)
|
||||
self.assertEqual(v.flow, 50)
|
||||
self.assertEqual(v.integral, 0)
|
||||
|
||||
def test_overheat_and_low_demand_stop_with_hysteresis(self):
|
||||
r = BoilerRegulator()
|
||||
self.assertTrue(r.update(18, 18, 5, 0).enabled)
|
||||
self.assertFalse(r.update(18, 18.6, 5, 60).enabled)
|
||||
self.assertFalse(r.update(18, 18.2, 5, 60).enabled)
|
||||
self.assertTrue(r.update(18, 18, 5, 60).enabled)
|
||||
self.assertFalse(r.update(18, 18, 20, 60).enabled)
|
||||
|
||||
def test_payload_preserves_sanitary_fields(self):
|
||||
a, b = setmode(40, True), setmode(0, False)
|
||||
self.assertEqual(a['disablehc'], 0)
|
||||
self.assertEqual(b['disablehc'], 1)
|
||||
for p in [a, b]:
|
||||
self.assertIsNone(p['hwctempdesired'])
|
||||
self.assertIsNone(p['hwcflowtempdesired'])
|
||||
self.assertEqual(p['disablehwctapping'], 0)
|
||||
self.assertEqual(p['disablehwcload'], 0)
|
||||
|
||||
def test_rejects_nonfinite_inputs(self):
|
||||
for value in [float('nan'), float('inf')]:
|
||||
with self.assertRaises(ValueError):
|
||||
BoilerRegulator().update(18, value, 5, 60)
|
||||
|
||||
|
||||
class ClimateTests(unittest.TestCase):
|
||||
def app(self):
|
||||
# Test the actual app callbacks without starting an AppDaemon server.
|
||||
app = object.__new__(BoilerClimate)
|
||||
app.base, app.boiler = 'appdaemon/test', 'boiler'
|
||||
app.mode, app.target = 'heat', 18
|
||||
app.indoor, app.outdoor = 'sensor.indoor', 'sensor.outdoor'
|
||||
app.min_target, app.max_target = 10, 23
|
||||
app.interval = 60
|
||||
app.controller = BoilerRegulator()
|
||||
app.send_commands = True
|
||||
app.last_payload = None
|
||||
app.last_send = app.previous_time = 1000
|
||||
app.last_command_at = app.last_echo_at = None
|
||||
app.telemetry, app.last_status_time = {}, 0
|
||||
app._publish = Mock()
|
||||
app._save = Mock()
|
||||
app.log = Mock()
|
||||
app.initialize_logger_interface(app, ascii_encode=False)
|
||||
readings = {app.indoor: 18, app.outdoor: 10}
|
||||
app._sensor_value = lambda entity: readings[entity]
|
||||
return app
|
||||
|
||||
def writes(self, app):
|
||||
return [c for c in app._publish.call_args_list if c.args[0] == 'boiler/bai/SetMode/set']
|
||||
|
||||
def test_single_change_and_periodic_refresh_only(self):
|
||||
a = self.app()
|
||||
with patch('boilerclimate.time.monotonic', return_value=1000):
|
||||
a._control()
|
||||
self.assertEqual(len(self.writes(a)), 1)
|
||||
with patch('boilerclimate.time.monotonic', return_value=1030):
|
||||
a._control()
|
||||
self.assertEqual(len(self.writes(a)), 1)
|
||||
with patch('boilerclimate.time.monotonic', return_value=1060):
|
||||
a._control()
|
||||
self.assertEqual(len(self.writes(a)), 2)
|
||||
for call in self.writes(a):
|
||||
self.assertFalse(call.args[2])
|
||||
|
||||
def test_invalid_sensor_explicitly_inhibits_heat(self):
|
||||
a = self.app()
|
||||
a._sensor_value = Mock(side_effect=ValueError('unavailable'))
|
||||
a._control()
|
||||
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
|
||||
self.assertEqual(a.controller.integral, 0)
|
||||
|
||||
def test_observation_mode_never_writes_boiler(self):
|
||||
a = self.app()
|
||||
a.send_commands = False
|
||||
a._control()
|
||||
self.assertEqual(self.writes(a), [])
|
||||
|
||||
def test_room_target_rejects_nan_and_out_of_bounds(self):
|
||||
a = self.app()
|
||||
for p in ['nan', 'inf', '30', 'garbage']:
|
||||
a._mqtt_event('', dict(topic=a.base + '/command/temperature', payload=p), {})
|
||||
self.assertEqual(a.target, 18)
|
||||
self.assertFalse(a._save.called)
|
||||
self.assertEqual(self.writes(a), [])
|
||||
|
||||
def test_off_command_sends_stop(self):
|
||||
a = self.app()
|
||||
a._mqtt_event('', dict(topic=a.base + '/command/mode', payload='off'), {})
|
||||
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
|
||||
|
||||
def test_native_ha_template_payload_is_accepted(self):
|
||||
a = self.app()
|
||||
a._mqtt_event('', dict(topic='boiler/bai/Statenumber', payload={'value': 30}), {})
|
||||
self.assertEqual(a.telemetry['Statenumber'], {'value': 30})
|
||||
self.assertEqual(self.writes(a), [])
|
||||
|
||||
def reading_app(self, state='18', age=0):
|
||||
a = self.app()
|
||||
stamp = (datetime.now(timezone.utc) - timedelta(seconds=age)).isoformat()
|
||||
a.get_state = Mock(return_value={
|
||||
'state': state,
|
||||
'attributes': {'unit_of_measurement': '°C', 'friendly_name': 'Couloir Température'},
|
||||
'last_updated': stamp,
|
||||
})
|
||||
return a
|
||||
|
||||
def test_unchanged_temperature_is_valid_even_after_days(self):
|
||||
a = self.reading_app(age=86400 * 3)
|
||||
self.assertEqual(BoilerClimate._sensor_value(a, a.indoor), 18)
|
||||
|
||||
def test_unavailable_is_invalid_without_waiting_two_hours(self):
|
||||
a = self.reading_app(state='unavailable', age=1)
|
||||
with self.assertRaisesRegex(ValueError, r'sensor.indoor.*invalid state.*unavailable'):
|
||||
BoilerClimate._sensor_value(a, a.indoor)
|
||||
|
||||
def test_missing_timestamp_does_not_invalidate_temperature(self):
|
||||
a = self.reading_app()
|
||||
a.get_state.return_value.pop('last_updated')
|
||||
self.assertEqual(BoilerClimate._sensor_value(a, a.indoor), 18)
|
||||
|
||||
def test_fault_logs_error_once_then_logs_recovery(self):
|
||||
a = self.app()
|
||||
a._sensor_value = Mock(side_effect=ValueError('sensor.indoor (Couloir): unavailable'))
|
||||
a._control()
|
||||
a._control()
|
||||
errors = [c for c in a.log.call_args_list
|
||||
if c.kwargs.get('level') == 'ERROR' and c.kwargs.get('log') == 'main_log']
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertIn('Couloir', errors[0].args[0])
|
||||
self.assertFalse(errors[0].kwargs['ascii_encode'])
|
||||
self.assertTrue(any(c.kwargs.get('log') == 'error_log' for c in a.log.call_args_list))
|
||||
self.assertIn('sensor.indoor', a.sensor_error)
|
||||
a._sensor_value = lambda entity: 18 if entity == a.indoor else 10
|
||||
a._control()
|
||||
self.assertIsNone(a.sensor_error)
|
||||
self.assertTrue(any('recovered' in c.args[0] for c in a.log.call_args_list))
|
||||
|
||||
def test_telemetry_does_not_refresh_command(self):
|
||||
a = self.app()
|
||||
a._mqtt_event('', dict(topic='boiler/bai/SetMode', payload=json.dumps(setmode(40, True))), {})
|
||||
self.assertEqual(self.writes(a), [])
|
||||
|
||||
def test_save_failure_does_not_block_off(self):
|
||||
a = self.app()
|
||||
a._save.side_effect = OSError('disk full')
|
||||
a._mqtt_event('', dict(topic=a.base + '/command/mode', payload='off'), {})
|
||||
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
|
||||
self.assertTrue(any('disk full' in c.args[0] for c in a.log.call_args_list))
|
||||
|
||||
def test_save_failure_does_not_block_shutdown(self):
|
||||
a = self.app()
|
||||
a._save.side_effect = OSError('disk full')
|
||||
a.terminate()
|
||||
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
|
||||
a._publish.assert_any_call(a.base + '/availability', 'offline', True)
|
||||
|
||||
def test_shutdown_publish_failure_still_saves_and_marks_offline(self):
|
||||
a = self.app()
|
||||
def publish(topic, *args):
|
||||
if topic == 'boiler/bai/SetMode/set':
|
||||
raise RuntimeError('service unavailable')
|
||||
a._publish.side_effect = publish
|
||||
a.terminate()
|
||||
a._save.assert_called_once()
|
||||
a._publish.assert_any_call(a.base + '/availability', 'offline', True)
|
||||
|
||||
def test_invalid_persisted_state_starts_off(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
for saved in [{'mode': 'off', 'target': 'invalid'},
|
||||
{'mode': 'heat', 'target': 30},
|
||||
{'target': 18}, [], None]:
|
||||
with self.subTest(saved=saved):
|
||||
a = self.app()
|
||||
a.state_file = Path(folder) / 'state.json'
|
||||
a.state_file.write_text(json.dumps(saved))
|
||||
a._restore()
|
||||
self.assertEqual(a.mode, 'off')
|
||||
self.assertEqual(a.target, 18)
|
||||
self.assertTrue(any(c.kwargs.get('level') == 'WARNING'
|
||||
for c in a.log.call_args_list))
|
||||
|
||||
def test_restore_valid_state_and_first_start(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
a = self.app()
|
||||
a.state_file = Path(folder) / 'state.json'
|
||||
a._restore()
|
||||
self.assertEqual(a.mode, 'heat')
|
||||
a.state_file.write_text(json.dumps({'mode': 'off', 'target': 19.5}))
|
||||
a._restore()
|
||||
self.assertEqual((a.mode, a.target), ('off', 19.5))
|
||||
|
||||
def test_corrupt_or_unreadable_state_starts_off(self):
|
||||
for contents in ['{broken', OSError('permission denied')]:
|
||||
with self.subTest(contents=contents):
|
||||
a = self.app()
|
||||
a.state_file = Mock()
|
||||
if isinstance(contents, OSError):
|
||||
a.state_file.read_text.side_effect = contents
|
||||
else:
|
||||
a.state_file.read_text.return_value = contents
|
||||
a._restore()
|
||||
self.assertEqual(a.mode, 'off')
|
||||
|
||||
def test_standard_log_pipeline_preserves_unicode_and_error_routing(self):
|
||||
a = self.app()
|
||||
a.log_info('Demande à 40 °C')
|
||||
a.log_warning('État invalide')
|
||||
a.log_error('Échec chaudière')
|
||||
a.log.assert_any_call('Demande à 40 °C', level='INFO', log='main_log', ascii_encode=False)
|
||||
a.log.assert_any_call('État invalide', level='WARNING', log='main_log', ascii_encode=False)
|
||||
a.log.assert_any_call('Échec chaudière', level='ERROR', log='error_log', ascii_encode=False)
|
||||
|
||||
def test_boiler_status_updates_action_without_advancing_regulation(self):
|
||||
a = self.app()
|
||||
a._control()
|
||||
a._publish.reset_mock()
|
||||
previous_time, integral = a.previous_time, a.controller.integral
|
||||
for status, action in [(4, 'heating'), (14, 'idle'), (0, 'idle')]:
|
||||
a._mqtt_event('', dict(topic='boiler/bai/Statenumber', payload={'value': status}), {})
|
||||
states = [c.args[1] for c in a._publish.call_args_list if c.args[0] == a.base + '/state']
|
||||
self.assertEqual(states[-1]['action'], action)
|
||||
self.assertEqual(self.writes(a), [])
|
||||
self.assertEqual(a.previous_time, previous_time)
|
||||
self.assertEqual(a.controller.integral, integral)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user