the boiler app now use mqtt instead of relying on an automation on ha side

This commit is contained in:
Pierre committed 2026-10-10 16:19:37 +02:00
1 parent a2f40f81ba
commit 6447d754ca
8 files changed
+261 -77

No files matched your search

+106
View File
@@ -11,6 +11,7 @@ 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):
@@ -68,8 +69,12 @@ 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.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()
@@ -89,6 +94,39 @@ class ClimateTests(unittest.TestCase):
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):
@@ -103,6 +141,34 @@ class ClimateTests(unittest.TestCase):
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'))
@@ -265,5 +331,45 @@ class ClimateTests(unittest.TestCase):
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()