feat: coordinate authoritative playback and audio routing
This commit is contained in:
+234
-1
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -9,6 +10,20 @@ 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 = []
|
||||
@@ -17,6 +32,9 @@ class FakeClient:
|
||||
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))
|
||||
@@ -42,7 +60,7 @@ class FakeClient:
|
||||
|
||||
def loop_start(self):
|
||||
self.calls.append(("loop_start",))
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
return self.loop_result
|
||||
|
||||
def loop_stop(self):
|
||||
self.calls.append(("loop_stop",))
|
||||
@@ -56,6 +74,12 @@ class FakeClient:
|
||||
|
||||
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):
|
||||
@@ -106,6 +130,66 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
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)
|
||||
@@ -117,6 +201,7 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
("youtube/player/telemetry", 0),
|
||||
("youtube/player/state", 0),
|
||||
("youtube/controller/cmd", 1),
|
||||
("audio/player/mode/set", 1),
|
||||
("audio/player/mode/state", 1),
|
||||
],
|
||||
)
|
||||
@@ -127,6 +212,7 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
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(
|
||||
@@ -140,6 +226,7 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
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")
|
||||
@@ -149,7 +236,75 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
("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(
|
||||
@@ -167,6 +322,7 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
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(
|
||||
@@ -184,6 +340,7 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
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(
|
||||
@@ -196,6 +353,82 @@ class MqttAdapterTests(unittest.TestCase):
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user