import json import queue import unittest from types import SimpleNamespace import paho.mqtt.client as mqtt from controller.config import ControllerConfig, MqttConfig from controller.mqtt_adapter import MqttAdapter class FakeClient: def __init__(self): self.calls = [] self.published = [] self.subscriptions = [] self.acks = [] self.suppress_exceptions = False self.connect_error = None def username_pw_set(self, username, password): self.calls.append(("username_pw_set", username, password)) def manual_ack_set(self, enabled): self.calls.append(("manual_ack_set", enabled)) def max_queued_messages_set(self, count): self.calls.append(("max_queued_messages_set", count)) def reconnect_delay_set(self, **kwargs): self.calls.append(("reconnect_delay_set", kwargs)) def connect_async(self, *args, **kwargs): self.calls.append(("connect_async", args, kwargs)) if self.connect_error is not None: raise self.connect_error return mqtt.MQTT_ERR_SUCCESS def connect(self, *args, **kwargs): self.calls.append(("connect", args, kwargs)) raise AssertionError("synchronous connect must never be used") def loop_start(self): self.calls.append(("loop_start",)) return mqtt.MQTT_ERR_SUCCESS def loop_stop(self): self.calls.append(("loop_stop",)) def disconnect(self): self.calls.append(("disconnect",)) def subscribe(self, topic, qos=0): self.subscriptions.append((topic, qos)) return mqtt.MQTT_ERR_SUCCESS, len(self.subscriptions) def publish(self, topic, payload, qos=0, retain=False): self.published.append((topic, payload, qos, retain)) return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=len(self.published)) def ack(self, message_id, qos): self.acks.append((message_id, qos)) return mqtt.MQTT_ERR_SUCCESS class MqttAdapterTests(unittest.TestCase): def setUp(self): self.client = FakeClient() self.events = queue.Queue() self.config = ControllerConfig( mqtt=MqttConfig( host="broker", port=1884, username="user", password="pass", client_id="controller-test", reconnect_min_seconds=1, reconnect_max_seconds=2, command_queue_size=8, ) ) self.adapter = MqttAdapter( self.config, client=self.client, events=self.events, ) def tearDown(self): self.adapter.stop() def test_start_is_asynchronous_and_configures_manual_ack(self): self.adapter.start() names = [call[0] for call in self.client.calls] self.assertIn("manual_ack_set", names) self.assertIn("connect_async", names) self.assertIn("loop_start", names) self.assertNotIn("connect", names) connect = next(call for call in self.client.calls if call[0] == "connect_async") self.assertEqual(connect[1], ("broker", 1884)) self.assertEqual(connect[2], {"keepalive": 30}) def test_initial_setup_oserror_does_not_escape_startup(self): self.client.connect_error = OSError("network unreachable") self.adapter.start() self.assertTrue(self.adapter.started) self.assertIn("network unreachable", self.adapter.last_error) self.assertNotIn("loop_start", [call[0] for call in self.client.calls]) def test_connect_subscribes_to_all_authoritative_inputs(self): self.adapter.start() self.adapter._on_connect(self.client, None, None, 0) self.assertTrue(self.adapter.connected) self.assertEqual( self.client.subscriptions, [ ("youtube/player/status", 1), ("youtube/player/telemetry", 0), ("youtube/player/state", 0), ("youtube/controller/cmd", 1), ("audio/player/mode/state", 1), ], ) event = self.events.get_nowait() self.assertEqual(event.kind, "connected") self.assertEqual(event.connection_generation, 1) def test_player_commands_are_exact_qos_one_and_nonretained(self): disconnected = self.adapter.publish_player_command({"action": "stop"}) self.assertFalse(disconnected.accepted) self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() receipt = self.adapter.publish_player_command( {"action": "play", "slot": 3, "label": "Opening"} ) self.assertTrue(receipt.accepted) topic, payload, qos, retain = self.client.published[-1] self.assertEqual(topic, "youtube/player/cmd") self.assertEqual(json.loads(payload), {"action": "play", "slot": 3, "label": "Opening"}) self.assertEqual(qos, 1) self.assertIs(retain, False) def test_router_commands_are_qos_one_nonretained_plain_strings(self): self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() receipt = self.adapter.publish_router_mode("video") self.assertTrue(receipt.accepted) self.assertEqual( self.client.published[-1], ("audio/player/mode/set", "video", 1, False), ) def test_qos_message_is_acked_only_after_consumer_calls_acknowledge(self): self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() message = SimpleNamespace( topic="youtube/controller/cmd", payload=b'{"action":"stop"}', qos=1, retain=False, mid=42, ) self.adapter._on_message(self.client, None, message) event = self.events.get_nowait() self.assertEqual(self.client.acks, []) self.assertEqual(event.message_id, 42) self.assertTrue(self.adapter.acknowledge(event)) self.assertEqual(self.client.acks, [(42, 1)]) def test_stale_mid_from_previous_connection_is_not_acked(self): self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() message = SimpleNamespace( topic="youtube/controller/cmd", payload=b'{"action":"stop"}', qos=1, retain=False, mid=7, ) self.adapter._on_message(self.client, None, message) old_event = self.events.get_nowait() self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() self.assertFalse(self.adapter.acknowledge(old_event)) self.assertEqual(self.client.acks, []) def test_retained_flag_is_preserved_for_coordinator_rejection(self): self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() message = SimpleNamespace( topic="youtube/controller/cmd", payload=b'{"action":"delete_slot","slot":1}', qos=1, retain=True, mid=8, ) self.adapter._on_message(self.client, None, message) self.assertIs(self.events.get_nowait().retain, True) if __name__ == "__main__": unittest.main()