Added a smart condition to the boiler

This commit is contained in:
Pierre committed 2026-10-11 10:13:37 +02:00
1 parent 6447d754ca
commit 79dd13f31d
4 files changed
+215 -6

No files matched your search

+40 -5
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import time
import appdaemon.plugins.hass.hassapi as hass
import ad_toolbox.smartcondition as SmartCondition
from boilerregulator import BoilerRegulator, finite, setmode
from logger_interface import LoggerInterface
@@ -19,6 +20,7 @@ class BoilerClimate(hass.Hass, LoggerInterface):
self.boiler = self.args.get('boiler_topic', 'themaplus-condens').rstrip('/')
self.connection_topic = self.args.get('mqtt_connection_topic', self.base + '/connection')
self.uid = self.args.get('unique_id', 'appdaemon_radiator_heating')
self.entity_id = self.args.get('entity', 'climate.radiator_heating')
self.title = self.args.get('display_name', 'Chauffage radiateurs')
self.indoor = self.args['indoor_sensor']
outdoors = self.args.get('outdoor_sensors')
@@ -54,10 +56,40 @@ class BoilerClimate(hass.Hass, LoggerInterface):
for entity in self.outdoors:
self.listen_state(self._sensor_changed, entity, attribute='all')
self._discovery()
self._initialize_smart_conditions()
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 _initialize_smart_conditions(self, kwargs=None):
if getattr(self, 'smart_conditions_evaluator', None) is not None:
return
self.smart_conditions_evaluator = None
if 'smart_conditions' in self.args:
if not self.entity_exists(self.entity_id) or self.get_state(self.entity_id, attribute='temperature') is None:
self.mode = 'off'
self.run_in(self._initialize_smart_conditions, 1)
return
constants = {**self.args.get('constants', {}), 'self': self.entity_id}
self.smart_conditions_evaluator = SmartCondition.Evaluator(
self, self.args['smart_conditions'], condition_name='smart_conditions',
on_change_cb=self.on_smart_conditions_change,
constants=constants)
def on_smart_conditions_change(self, previous, result):
if result == SmartCondition.Result.Disabled:
return
mode = 'heat' if result == SmartCondition.Result.Succeeded else 'off'
if mode != self.mode:
self._set_mode(mode, 'SmartCondition')
def _set_mode(self, mode, source):
self.log_info(f'Climate mode: {self.mode} -> {mode} ({source})')
self.mode = mode
self.controller.reset()
self._control(force=True)
self._save_safely()
def _publish(self, topic, payload, retain=False):
if not isinstance(payload, str):
payload = json.dumps(payload, allow_nan=False, ensure_ascii=False)
@@ -71,7 +103,7 @@ class BoilerClimate(hass.Hass, LoggerInterface):
{'topic': self.connection_topic}],
availability_mode='all')
config = dict(common, name=None, unique_id=self.uid,
default_entity_id='climate.radiator_heating',
default_entity_id=self.entity_id,
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',
@@ -170,10 +202,7 @@ class BoilerClimate(hass.Hass, LoggerInterface):
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()
self._set_mode(payload, 'MQTT')
elif topic == self.base + '/command/temperature':
try:
target = finite(payload)
@@ -286,11 +315,17 @@ class BoilerClimate(hass.Hass, LoggerInterface):
# on boiler command expiration. No retained actuator command, ever.
if not hasattr(self, 'base'):
return
evaluator = getattr(self, 'smart_conditions_evaluator', None)
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}')
if evaluator is not None:
try:
evaluator.deactivate()
except Exception as exc:
self.log_warning(f'Could not deactivate SmartCondition: {exc}')
try:
self._publish(self.base + '/availability', 'offline', True)
except Exception as exc:
+9
View File
@@ -221,6 +221,7 @@ guestroom_heatpump:
radiator_heating:
module: boilerclimate
class: BoilerClimate
entity: climate.radiator_heating
display_name: Chauffage radiateurs
unique_id: appdaemon_radiator_heating
indoor_sensor: sensor.corridor_temperature
@@ -230,6 +231,14 @@ radiator_heating:
target_temperature: 18
initial_mode: heat
smart_conditions:
local_expressions:
outdoor_temperature: "sensor.ext_front_temperature < sensor.ext_back_temperature ? sensor.ext_front_temperature : sensor.ext_back_temperature"
trigger_conditions: binary_sensor.someone_home and outdoor_temperature < self.temperature
blocking_conditions:
- binary_sensor.group_windows
- binary_sensor.kitchen_french_door or binary_sensor.living_room_french_door or binary_sensor.bedroom_french_door
# Radiator heating enabled with the room-thermostat bridge CLOSED.
# false keeps the climate/diagnostics but suppresses SetMode writes.
send_commands: true
+34
View File
@@ -28,6 +28,40 @@ télémétrie en MQTT direct. Aucun relais MQTT dans Home Assistant n'est néces
## Régulation
Le bloc facultatif `smart_conditions` contrôle le mode `heat`/`off` avec le même
évaluateur que les autres apps : `Succeeded` passe en `heat`, `Failed` passe
en `off`. L'évaluation initiale est appliquée au démarrage, puis chaque changement
de résultat. Un résultat `Disabled` suspend l'automatisme et conserve le mode.
Les commandes manuelles restent possibles ; elles restent en vigueur jusqu'au
prochain changement de résultat de la condition ou redémarrage de l'app.
Le changement de mode envoie immédiatement la commande chaudière et est persisté.
La consigne ambiante n'est pas modifiée et les sécurités capteurs restent actives.
Sans ce bloc, le mode reste entièrement manuel comme auparavant. Exemple :
```yaml
smart_conditions:
trigger_conditions:
- binary_sensor.heatpump_winter_mode
```
Les options standard (`blocking_conditions`, `disable_conditions`,
`callback_delay`, constantes locales) sont prises en charge.
La règle active demande `heat` uniquement si quelqu'un est à la maison,
si le minimum extérieur est inférieur à la consigne ambiante (`self.temperature`),
et si toutes les fenêtres et portes-fenêtres surveillées sont
fermées. `binary_sensor.group_windows` couvre bureau, salon, chambre d'amis
et dressing ; les portes-fenêtres cuisine, salon et chambre sont ajoutées
séparément. La règle utilise les états booléens de présence et d'ouverture.
Le mode est recalculé au démarrage et lors des changements de résultat de la
règle, sans temporisation ajoutée. Après un changement de consigne, la règle
est réévaluée lorsque Home Assistant actualise l'attribut `temperature` du climate.
`self` désigne l'entité configurée par `entity: climate.radiator_heating`.
Toutes les expressions lisent les états Home Assistant normalement. Au premier
démarrage, le chauffage reste en `off` pendant l'attente de l'entité et de sa
consigne, avant de créer l'évaluateur. Celui-ci est ensuite conservé pendant
toute la durée de vie de l'app, y compris lors des changements de consigne.
Avec S la consigne ambiante, Ti la température du couloir et Te la plus basse
des températures extérieures (`sensor.ext_front_temperature` et
`sensor.ext_back_temperature`, configurées dans `outdoor_sensors`) :
+132 -1
View File
@@ -5,12 +5,13 @@ import tempfile
from pathlib import Path
from types import SimpleNamespace
import unittest
import yaml
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
from boilerclimate import BoilerClimate, SmartCondition
from boilerwatchdog import BoilerWatchdog
@@ -69,6 +70,7 @@ class ClimateTests(unittest.TestCase):
# Test the actual app callbacks without starting an AppDaemon server.
app = object.__new__(BoilerClimate)
app.base, app.boiler = 'appdaemon/test', 'boiler'
app.entity_id = 'climate.radiator_heating'
app.mqtt_namespace = 'mqtt'
app.connection_topic = app.base + '/connection'
app.mode, app.target = 'heat', 18
@@ -86,6 +88,8 @@ class ClimateTests(unittest.TestCase):
app._publish = Mock()
app._save = Mock()
app.log = Mock()
app.entity_exists = Mock(return_value=True)
app.get_state = Mock(return_value=18)
app.initialize_logger_interface(app, ascii_encode=False)
readings = {app.indoor: 18, app.outdoor: 10}
app._sensor_value = lambda entity: readings[entity]
@@ -195,6 +199,133 @@ class ClimateTests(unittest.TestCase):
a._mqtt_event('', dict(topic=a.base + '/command/mode', payload='off'), {})
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
def test_smart_conditions_without_configuration_keep_manual_mode(self):
a = self.app()
a.args = {}
a._initialize_smart_conditions()
self.assertIsNone(a.smart_conditions_evaluator)
self.assertEqual(a.mode, 'heat')
a._publish.assert_not_called()
def test_smart_condition_evaluates_initial_state_and_sensor_changes(self):
a = self.app()
a.args = {'smart_conditions': {'trigger_conditions': ['binary_sensor.test_heating']}}
a.entity_exists = Mock(return_value=True)
a.get_state = Mock(return_value='off')
a.listen_state = Mock(return_value='condition_callback')
a.cancel_listen_state = Mock()
a._initialize_smart_conditions()
self.assertEqual(a.mode, 'off')
self.assertEqual(self.writes(a)[-1].args[1]['disablehc'], 1)
callback = a.listen_state.call_args.args[0]
a.get_state.return_value = 'on'
callback('binary_sensor.test_heating', 'state', 'off', 'on', {})
self.assertEqual(a.mode, 'heat')
self.assertEqual(self.writes(a)[-1].args[1]['disablehc'], 0)
self.assertEqual(a.target, 18)
a.get_state.return_value = 'off'
callback('binary_sensor.test_heating', 'state', 'on', 'off', {})
self.assertEqual(a.mode, 'off')
a.terminate()
a.cancel_listen_state.assert_called_once_with('condition_callback')
def test_smart_condition_disabled_preserves_manual_mode(self):
a = self.app()
a.on_smart_conditions_change(None, SmartCondition.Result.Disabled)
self.assertEqual(a.mode, 'heat')
a._publish.assert_not_called()
def test_smart_condition_does_not_bypass_invalid_sensor(self):
a = self.app()
a.mode = 'off'
a._sensor_value = Mock(side_effect=ValueError('unavailable'))
a.on_smart_conditions_change(None, SmartCondition.Result.Succeeded)
self.assertEqual(a.mode, 'heat')
self.assertEqual(self.writes(a)[-1].args[1]['disablehc'], 1)
def test_condition_cleanup_failure_does_not_block_shutdown(self):
a = self.app()
a.smart_conditions_evaluator = Mock()
a.smart_conditions_evaluator.deactivate.side_effect = RuntimeError('cleanup failed')
a.terminate()
self.assertEqual(self.writes(a)[-1].args[1]['disablehc'], 1)
a._save.assert_called_once()
def test_configured_presence_weather_and_window_rule(self):
config = yaml.safe_load((Path(__file__).resolve().parents[1] /
'apps/global/heating.yaml').read_text())['radiator_heating']
baseline = {'binary_sensor.someone_home': 'on',
'sensor.ext_front_temperature': '30', 'sensor.ext_back_temperature': '10',
'binary_sensor.group_windows': 'off',
'binary_sensor.kitchen_french_door': 'off',
'binary_sensor.living_room_french_door': 'off',
'binary_sensor.bedroom_french_door': 'off'}
cases = [({}, 'heat'),
({'binary_sensor.someone_home': 'off'}, 'off'),
({'sensor.ext_back_temperature': '18'}, 'off'),
({'sensor.ext_front_temperature': '10', 'sensor.ext_back_temperature': '30'}, 'heat')]
for entity in ['binary_sensor.group_windows', 'binary_sensor.kitchen_french_door',
'binary_sensor.living_room_french_door', 'binary_sensor.bedroom_french_door']:
cases.append(({entity: 'on'}, 'off'))
for changes, expected in cases:
with self.subTest(changes=changes):
a = self.app()
a.mode = 'off'
a.args = config
states = {**baseline, **changes}
a.entity_exists = lambda entity: entity in states or entity == a.entity_id
a.get_state = lambda entity, **kwargs: a.target if entity == a.entity_id else states[entity]
a.listen_state = Mock(return_value='condition_callback')
a._initialize_smart_conditions()
self.assertEqual(a.mode, expected)
listened = {c.args[1] for c in a.listen_state.call_args_list}
self.assertTrue(set(baseline).issubset(listened))
def test_target_change_is_evaluated_when_ha_attribute_updates(self):
a = self.app()
a.args = {'smart_conditions': {
'trigger_conditions': ['sensor.outside < self.temperature']}}
a.entity_exists = Mock(return_value=True)
states = {a.entity_id: 18, 'sensor.outside': 20}
a.get_state = lambda entity, **kwargs: states[entity]
a.listen_state = Mock(return_value='condition_callback')
a.cancel_listen_state = Mock()
a._initialize_smart_conditions()
self.assertEqual(a.mode, 'off') # 20 outside >= target 18.
evaluator = a.smart_conditions_evaluator
callback = next(c.args[0] for c in a.listen_state.call_args_list
if c.args[1] == a.entity_id and c.kwargs.get('attribute') == 'temperature')
for target, mode in [(23, 'heat'), (20, 'off'), (21, 'heat')]:
previous_mode = a.mode
old_target = states[a.entity_id]
a._mqtt_event('', dict(topic=a.base + '/command/temperature', payload=str(target)), {})
self.assertEqual(a.mode, previous_mode) # Wait for HA's attribute update.
states[a.entity_id] = target
callback(a.entity_id, 'temperature', old_target, target, {})
self.assertEqual(a.mode, mode)
self.assertEqual(a.target, target)
self.assertIs(a.smart_conditions_evaluator, evaluator)
a.cancel_listen_state.assert_not_called()
def test_smart_conditions_wait_for_discovered_entity_and_temperature(self):
a = self.app()
a.mode, a.target = 'off', 23
a.args = {'smart_conditions': {'trigger_conditions': ['sensor.outside < self.temperature']}}
a.entity_exists = Mock(return_value=False)
a.get_state = Mock(return_value=None)
a.run_in = Mock()
a.listen_state = Mock(return_value='condition_callback')
a._initialize_smart_conditions()
self.assertEqual(a.mode, 'off')
self.assertIsNone(a.smart_conditions_evaluator)
a.run_in.assert_called_once_with(a._initialize_smart_conditions, 1)
a.entity_exists.return_value = True
a._initialize_smart_conditions({}) # Entity exists but its target is not ready.
self.assertIsNone(a.smart_conditions_evaluator)
a.get_state = lambda entity, **kwargs: 20 if entity == 'sensor.outside' else 23
a._initialize_smart_conditions()
self.assertEqual(a.mode, 'heat')
def test_native_ha_template_payload_is_accepted(self):
a = self.app()
a._mqtt_event('', dict(topic='boiler/bai/Statenumber', payload={'value': 30}), {})