376 lines
16 KiB
Python
376 lines
16 KiB
Python
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
|
|
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.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.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_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()
|