Added a smart condition to the boiler
This commit is contained in:
1 parent
6447d754ca
commit
79dd13f31d
4 files changed
+215
-6
No files matched your search
+40
-5
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}), {})
|
||||
|
||||
Reference in new issue
Block a user