import json import queue import threading 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 FakeMessageInfo: def __init__(self, mid, published): self.rc = mqtt.MQTT_ERR_SUCCESS self.mid = mid self.published = published self.waited = [] def wait_for_publish(self, timeout=None): self.waited.append(timeout) def is_published(self): return self.published class FakeClient: def __init__(self): self.calls = [] self.published = [] self.subscriptions = [] self.acks = [] self.suppress_exceptions = False self.connect_error = None self.loop_result = mqtt.MQTT_ERR_SUCCESS self.publish_acknowledged = None self.publish_hook = 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 self.loop_result 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)) if self.publish_hook is not None: self.publish_hook(topic, payload, qos, retain) if self.publish_acknowledged is not None: return FakeMessageInfo( len(self.published), self.publish_acknowledged ) 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_loop_start_failure_does_not_escape_startup(self): self.client.loop_result = mqtt.MQTT_ERR_UNKNOWN self.adapter.start() self.assertTrue(self.adapter.started) self.assertIn("failed to start MQTT network loop", self.adapter.last_error) def test_stop_during_blocked_connect_never_starts_network_loop_afterward(self): entered = threading.Event() release = threading.Event() original_connect = self.client.connect_async def blocked_connect(*args, **kwargs): entered.set() release.wait(timeout=2) return original_connect(*args, **kwargs) self.client.connect_async = blocked_connect starter = threading.Thread(target=self.adapter.start) starter.start() self.assertTrue(entered.wait(timeout=1)) self.adapter.stop() release.set() starter.join(timeout=2) self.assertFalse(starter.is_alive()) self.assertNotIn("loop_start", [call[0] for call in self.client.calls]) self.assertFalse(self.adapter.started) def test_stop_wins_against_connect_callback_blocked_during_subscribe(self): self.adapter.start() entered = threading.Event() release = threading.Event() original_subscribe = self.client.subscribe def blocked_subscribe(topic, qos=0): entered.set() release.wait(timeout=2) return original_subscribe(topic, qos=qos) self.client.subscribe = blocked_subscribe callback = threading.Thread( target=self.adapter._on_connect, args=(self.client, None, None, 0), ) callback.start() self.assertTrue(entered.wait(timeout=1)) stopper = threading.Thread(target=self.adapter.stop) stopper.start() release.set() callback.join(timeout=2) stopper.join(timeout=2) self.assertFalse(callback.is_alive()) self.assertFalse(stopper.is_alive()) self.assertFalse(self.adapter.started) self.assertFalse(self.adapter.connected) self.assertLess(len(self.client.subscriptions), 6) self.assertTrue(self.events.empty()) self.assertFalse( self.adapter.publish_player_command({"action": "stop"}).accepted ) 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/set", 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.start() 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.start() 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_router_self_echo_is_marked_only_while_publish_is_in_flight(self): self.adapter.start() self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() def echo(topic, payload, qos, retain): self.adapter._on_message( self.client, None, SimpleNamespace( topic=topic, payload=payload.encode("utf-8"), qos=qos, retain=retain, dup=False, mid=31, ), ) self.client.publish_hook = echo self.assertTrue(self.adapter.publish_router_mode("video").accepted) self.assertTrue(self.events.get_nowait().controller_echo) self.client.publish_hook = None self.adapter._on_message( self.client, None, SimpleNamespace( topic="audio/player/mode/set", payload=b"video", qos=1, retain=False, dup=False, mid=32, ), ) self.assertFalse(self.events.get_nowait().controller_echo) def test_retained_router_set_never_consumes_controller_echo_marker(self): self.adapter.start() self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() def retained_then_live_echo(topic, payload, qos, retain): for message_retain, mid in ((True, 41), (False, 42)): self.adapter._on_message( self.client, None, SimpleNamespace( topic=topic, payload=payload.encode("utf-8"), qos=qos, retain=message_retain, dup=False, mid=mid, ), ) self.client.publish_hook = retained_then_live_echo self.assertTrue(self.adapter.publish_router_mode("video").accepted) retained_event = self.events.get_nowait() live_event = self.events.get_nowait() self.assertTrue(retained_event.retain) self.assertFalse(retained_event.controller_echo) self.assertFalse(live_event.retain) self.assertTrue(live_event.controller_echo) def test_qos_message_is_acked_only_after_consumer_calls_acknowledge(self): self.adapter.start() 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.start() 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.start() 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) def test_duplicate_flag_is_preserved_for_coordinator_deduplication(self): self.adapter.start() self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() message = SimpleNamespace( topic="youtube/controller/cmd", payload=b'{"action":"toggle"}', qos=1, retain=False, dup=True, mid=9, ) self.adapter._on_message(self.client, None, message) self.assertIs(self.events.get_nowait().duplicate, True) def test_missing_puback_is_uncertain_and_invalidates_connection(self): self.adapter.start() self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() self.client.publish_acknowledged = False receipt = self.adapter.publish_player_command({"action": "stop"}) self.assertFalse(receipt.accepted) self.assertTrue(receipt.uncertain) self.assertIn("no broker PUBACK", receipt.reason) self.assertFalse(self.adapter.connected) def test_optional_qos_event_puback_failure_does_not_invalidate_transport(self): self.adapter.start() self.adapter._on_connect(self.client, None, None, 0) self.events.get_nowait() self.client.publish_acknowledged = False receipt = self.adapter.publish_json( "youtube/controller/v2/events", {"version": 2, "type": "warning"}, qos=1, retain=False, ) self.assertFalse(receipt.accepted) self.assertTrue(receipt.uncertain) self.assertTrue(self.adapter.connected) def test_discarded_transport_releases_callback_blocked_on_full_queue(self): events = queue.Queue(maxsize=1) client = FakeClient() adapter = MqttAdapter(self.config, client=client, events=events) try: adapter.start() adapter._on_connect(client, None, None, 0) self.assertTrue(events.full()) callback = threading.Thread( target=adapter._on_message, args=( client, None, SimpleNamespace( topic="youtube/controller/cmd", payload=b'{"action":"stop"}', qos=1, retain=False, dup=False, mid=99, ), ), ) callback.start() callback.join(timeout=0.05) self.assertTrue(callback.is_alive()) adapter._discard_unacknowledged_transport("test transport discard") callback.join(timeout=1) self.assertFalse(callback.is_alive()) self.assertEqual(events.qsize(), 1) self.assertEqual(events.get_nowait().kind, "connected") finally: adapter.stop() if __name__ == "__main__": unittest.main()