diff --git a/apps/boilerclimate.py b/apps/boilerclimate.py index 90b4c52..e2006ed 100644 --- a/apps/boilerclimate.py +++ b/apps/boilerclimate.py @@ -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: diff --git a/apps/global/heating.yaml b/apps/global/heating.yaml index d7cf29e..b5f3318 100644 --- a/apps/global/heating.yaml +++ b/apps/global/heating.yaml @@ -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 diff --git a/docs/radiator-heating.md b/docs/radiator-heating.md index dc42d45..4c6edda 100644 --- a/docs/radiator-heating.md +++ b/docs/radiator-heating.md @@ -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`) : diff --git a/tests/test_boilerclimate.py b/tests/test_boilerclimate.py index 4fa8780..54f52f9 100644 --- a/tests/test_boilerclimate.py +++ b/tests/test_boilerclimate.py @@ -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}), {})