feat: reduce authoritative player mqtt state

This commit is contained in:
Media
2026-08-06 17:41:07 -05:00
parent 45083207b4
commit 8b3bdae8a0
5 changed files with 1357 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
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()