Files
ad_trident/tests/test_boilerclimate.py
T

507 lines
23 KiB
Python

import json
from datetime import datetime, timedelta, timezone
import sys
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, SmartCondition
from boilerwatchdog import BoilerWatchdog
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.entity_id = 'climate.radiator_heating'
app.mqtt_namespace = 'mqtt'
app.connection_topic = app.base + '/connection'
app.mode, app.target = 'heat', 18
app.indoor, app.outdoor = 'sensor.indoor', 'sensor.outdoor'
app.outdoors = (app.outdoor,)
app.selected_outdoor = None
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.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]
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_mqtt_transport_uses_plugin_namespace(self):
a = self.app()
a.call_service = Mock()
BoilerClimate._publish(a, 'boiler/bai/SetMode/set', setmode(40, True))
call = a.call_service.call_args
self.assertEqual(call.args, ('mqtt/publish',))
self.assertEqual(call.kwargs['namespace'], 'mqtt')
self.assertFalse(call.kwargs['retain'])
self.assertEqual(json.loads(call.kwargs['payload']), setmode(40, True))
def test_discovery_requires_app_and_connection_online(self):
a = self.app()
a.uid, a.title = 'test_boiler', 'Test boiler'
a._discovery()
config = a._publish.call_args_list[0].args[1]
self.assertNotIn('availability_topic', config)
self.assertEqual(config['availability_mode'], 'all')
self.assertEqual(config['availability'], [{'topic': a.base + '/availability'},
{'topic': a.connection_topic}])
def test_mqtt_reconnect_renews_discovery_and_command(self):
a = self.app()
a._discovery = Mock()
a._mqtt_event('MQTT_MESSAGE', {'state': 'Connected', 'topic': None}, {})
a._discovery.assert_called_once()
self.assertEqual(len(self.writes(a)), 1)
def test_mqtt_disconnect_does_not_publish_or_advance_controller(self):
a = self.app()
a._mqtt_event('MQTT_MESSAGE', {'state': 'Disconnected', 'topic': None}, {})
a._publish.assert_not_called()
self.assertEqual(a.previous_time, 1000)
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_lowest_outdoor_reading_controls_regulation(self):
a = self.app()
a.target = 23
a.outdoors = ('sensor.front', 'sensor.back')
readings = {a.indoor: 22.6, 'sensor.front': 30.2, 'sensor.back': 23.7}
a._sensor_value = lambda entity: readings[entity]
a._control()
self.assertEqual(a._state['outdoor'], 23.7)
self.assertEqual(a.selected_outdoor, 'sensor.back')
self.assertTrue(a._state['heating_requested'])
readings['sensor.front'] = 20
a._sensor_changed('sensor.front', 'all', None, None, {})
self.assertEqual(a._state['outdoor'], 20)
self.assertEqual(a.selected_outdoor, 'sensor.front')
def test_invalid_second_outdoor_sensor_still_inhibits_heat(self):
a = self.app()
a.outdoors = ('sensor.front', 'sensor.back')
def reading(entity):
if entity == 'sensor.back':
raise ValueError('sensor.back: unavailable')
return 18 if entity == a.indoor else 5
a._sensor_value = reading
a._control()
self.assertEqual(self.writes(a)[0].args[1]['disablehc'], 1)
self.assertEqual(a._state['reason'], 'invalid_sensor')
self.assertIn('sensor.back', a.sensor_error)
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_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}), {})
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)
class WatchdogTests(unittest.TestCase):
def app(self):
a = object.__new__(BoilerWatchdog)
a.base, a.mqtt_namespace, a.timeout = 'appdaemon/test', 'mqtt', 180
a.last_heartbeat, a.fault = None, False
a.call_service, a.log = Mock(), Mock()
a.initialize_logger_interface(a, ascii_encode=False)
return a
def test_missing_heartbeat_publishes_offline_once(self):
a = self.app()
a._check()
a._check()
a.call_service.assert_called_once_with('mqtt/publish', namespace='mqtt',
topic=a.base + '/availability', payload='offline', qos=0, retain=True)
def test_timeout_and_recovery_do_not_override_sensor_availability(self):
a = self.app()
with patch('boilerwatchdog.time.monotonic', return_value=1000):
a._mqtt_event('', {'topic': a.base + '/heartbeat', 'payload': 'timestamp'}, {})
with patch('boilerwatchdog.time.monotonic', return_value=1179):
a._check()
a.call_service.assert_not_called()
with patch('boilerwatchdog.time.monotonic', return_value=1180):
a._check()
self.assertTrue(a.fault)
a.call_service.reset_mock()
a._mqtt_event('', {'topic': a.base + '/heartbeat'}, {})
self.assertFalse(a.fault)
a.call_service.assert_not_called() # The climate alone decides online/offline for sensor faults.
def test_reconnection_waits_for_new_heartbeat(self):
a = self.app()
a.last_heartbeat = 1000
a._mqtt_event('', {'topic': None, 'state': 'Connected'}, {})
self.assertIsNone(a.last_heartbeat)
self.assertTrue(a.fault)
self.assertEqual(a.call_service.call_args.kwargs['payload'], 'offline')
if __name__ == '__main__':
unittest.main()