The t19 review's findings on the protocol transport, all of them closed here. O1 (MEDIUM, FIRST). `_note_protocol_state(PROTOCOL_CONNECTED, None)` only assigned `last_error` when its argument was non-None, so a failure kept being published after the transport recovered: the review measured `protocol_last_error == "connect: Net: boom"` beside `protocol_connected: true` — a contradiction on the exact attributes the diagnostic surface was built for, so the user could not answer "is the protocol working?" without a device test. The pair now has ONE stated meaning, in the code that owns it and in the remote's attribute docs: `protocol_state`/`protocol_connected` is the transport's CURRENT condition, and `protocol_last_error` is the failure the current state has NOT recovered from. A session becoming proven CLEARS it — and a session becomes proven in BOTH places that can prove one, so the connect success and the transport's own availability push now take the same route (the push previously assigned the state directly and would have left the stale error again). `protocol_failures` keeps counting, so a recovery stays visible as history rather than as a stale message. A failure that happens AFTER the session came up is still kept: that one is current, not stale, and a test pins that distinction so a future "just clear it everywhere" refactor cannot make failures invisible. O4 + O5 (the lifecycle leaks, now real tests instead of observations). The connect attempt was never in the manager's task collection, so `async_stop` never cancelled it — a reconnect loop could outlive its entry, and since every options save reloads the entry those loops could accumulate across saves. `async_stop` also only SCHEDULED its cancellations. The attempt is now tracked, `async_stop` cancels AND awaits every tracked task, and the stop path refuses to start anything new. A double start is deterministic instead of orphaning the first: a live attempt is reused, an already-handed-over session (connected or disconnected — the library's keep_reconnecting owns it) starts nothing, and only a FINISHED attempt that never got a session may be replaced. The guard sits before the push callbacks are registered, so a second start cannot double-register them either. O2 (LOW). The connect banner claimed liveliness a successful `async_connect()` cannot know. It now says exactly what it proved (the client accepted a session) and hands liveness to what can actually report it (the transport's own availability push). STALE is defined where the fixes live: a banner is stale when a later state change never followed it up, so the log's last word contradicts the entity. Every transition therefore announces itself with both states — INFO for the two a user acts on, DEBUG otherwise — which is what makes the last transport line always match the published state. O3 (LOW). The drift floors now carry an explicit MARGIN POLICY instead of leaving a 2-margin unexplained: a floor is a vacuity-and-blindness alarm, not a size ratchet (it must fail when the walk stops COVERING something, not when a legitimate change removes code). Owner-counters keep a 2 margin (the project's own t62 canary, now stated once), statement_blocks moves to a ~2% band (28 blocks — the t19 complaint was that a 23-statement refactor could trip it), relative_imports ~2.5%, and the awaited-calls floor is called out plainly as the weakest one because a percentage margin on six sites is meaningless. The rule for changing any floor is written down with it. The walk is now 1648 blocks, so the floor is re-derived: 1615. EVIDENCE THAT THE TESTS PIN THE FIX (not merely pass): the same test file was run against the PRE-fix source (`git archive HEAD`, a throwaway copy under /tmp) and 13 of the 15 new tests FAIL there. The two that pass pre-fix are the deliberate anti-overcorrection pins: a failure that is still current keeps its message, and a terminal (auth-rejected) attempt may still be replaced. No regressions: 889 passed / 5 skipped, 890 / 4 with androidtvremote2, the real-HA pattern (3 files) 74 passed with no skips, ruff clean, drift exit 0. NOT VERIFIED HERE: no real Shield and no paired device, so no real TLS session, certificate or push was ever received — the connect path runs against doubles plus the installed library's real source, and the *timing* of a cancellation during a live handshake is inferred from the awaited-cancel path, not observed on hardware.
1086 lines
43 KiB
Python
1086 lines
43 KiB
Python
"""Unit tests for the protocol transport (androidtvremote2, Phase 2, t8).
|
|
|
|
Fake client mirrors AndroidTVRemote's surface (callback registration, an
|
|
``async_connect`` that must run BEFORE ``keep_reconnecting``, a self-owned
|
|
reconnect task that is a no-op until a session exists, sync ``disconnect``);
|
|
no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization
|
|
against the real library's VolumeInfo TypedDict when the package is present
|
|
(`uvx --with androidtvremote2 pytest`).
|
|
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
from nsh.connection import (
|
|
FIELD_AUDIO_PLAYBACK,
|
|
FIELD_AWAKE,
|
|
FIELD_FOREGROUND,
|
|
FIELD_IS_AVAILABLE,
|
|
FIELD_IS_ON,
|
|
FIELD_RUNNING_APPS,
|
|
FIELD_SESSIONS,
|
|
FIELD_VOLUME_INFO,
|
|
PROTOCOL_CONNECTED,
|
|
ShieldConnectionManager,
|
|
StateStore,
|
|
volume_tuple,
|
|
)
|
|
|
|
|
|
class InvalidAuth(Exception): # noqa: N818 - the library's own class name
|
|
"""The library's InvalidAuth, matched by NAME (the manager never imports it)."""
|
|
|
|
|
|
class CannotConnect(Exception): # noqa: N818 - the library's own class name
|
|
"""The library's CannotConnect — a retryable transport failure."""
|
|
|
|
|
|
class ConnectionClosed(Exception): # noqa: N818 - the library's own class name
|
|
"""The library's ConnectionClosed — raised when nothing is connected."""
|
|
|
|
|
|
class FakeProtocolClient:
|
|
"""AndroidTVRemote-shaped fake: the library's ORDER, reproduced.
|
|
|
|
t15: the real ``AndroidTVRemote`` keeps ``_remote_message_protocol = None`` until
|
|
``async_connect()`` succeeds, and ``keep_reconnecting()`` starts a loop whose
|
|
condition is ``while self._remote_message_protocol:`` — so calling it alone
|
|
starts a task that returns immediately and NOTHING ever connects (no push, and
|
|
every send raises ConnectionClosed). The fake reproduces that, and records which
|
|
of the two calls the manager made, so a regression back to keep_reconnecting-only
|
|
fails a test instead of silently disabling the transport.
|
|
"""
|
|
|
|
def __init__(self, connect_error: Exception | None = None):
|
|
self._remote_message_protocol = None # library attribute: None until connected
|
|
self._connect_error = connect_error
|
|
self.connect_calls = 0
|
|
self.keep_reconnecting_calls = 0
|
|
self.keys_sent: list = []
|
|
self.apps_launched: list = []
|
|
self.texts_sent: list = []
|
|
self._is_on_callbacks: list = []
|
|
self._is_available_callbacks: list = []
|
|
self._current_app_callbacks: list = []
|
|
self._volume_callbacks: list = []
|
|
self._invalid_auth_callback = None
|
|
self._reconnect_task = None
|
|
self.disconnect_calls = 0
|
|
|
|
# -- registration (mirrors the real API) --
|
|
def add_is_on_updated_callback(self, callback):
|
|
self._is_on_callbacks.append(callback)
|
|
|
|
def add_is_available_updated_callback(self, callback):
|
|
self._is_available_callbacks.append(callback)
|
|
|
|
def add_current_app_updated_callback(self, callback):
|
|
self._current_app_callbacks.append(callback)
|
|
|
|
def add_volume_info_updated_callback(self, callback):
|
|
self._volume_callbacks.append(callback)
|
|
|
|
# -- pushes (test helper: simulates device-side messages) --
|
|
def push_is_on(self, on):
|
|
for cb in list(self._is_on_callbacks):
|
|
cb(on)
|
|
|
|
def push_is_available(self, available):
|
|
for cb in list(self._is_available_callbacks):
|
|
cb(available)
|
|
|
|
def push_current_app(self, app_package):
|
|
for cb in list(self._current_app_callbacks):
|
|
cb(app_package)
|
|
|
|
def push_volume_info(self, info):
|
|
for cb in list(self._volume_callbacks):
|
|
cb(info)
|
|
|
|
|
|
# -- lifecycle --
|
|
def keep_reconnecting(self, invalid_auth_callback=None):
|
|
"""Own task like the real library — and, like it, a NO-OP until connected.
|
|
|
|
The condition is the library's own: with no session the task runs once,
|
|
finds nothing to maintain and returns. That is exactly why
|
|
``async_connect()`` has to come first.
|
|
"""
|
|
self.keep_reconnecting_calls += 1
|
|
self._invalid_auth_callback = invalid_auth_callback
|
|
loop = asyncio.get_event_loop()
|
|
self._reconnect_task = loop.create_task(self._reconnect_loop())
|
|
|
|
async def _reconnect_loop(self):
|
|
while self._remote_message_protocol: # the library's own guard
|
|
await asyncio.sleep(0.05)
|
|
|
|
# -- connect (t15: the call the manager used to be missing entirely) --
|
|
async def async_connect(self):
|
|
"""Connect; raise ``self._connect_error`` when the test wants a failure."""
|
|
self.connect_calls += 1
|
|
if self._connect_error is not None:
|
|
raise self._connect_error
|
|
self._remote_message_protocol = object() # library sets its RemoteProtocol
|
|
|
|
# -- sends (t15: the library raises when nothing is connected) --
|
|
def send_key_command(self, key, direction="SHORT"):
|
|
if not self._remote_message_protocol:
|
|
raise ConnectionClosed("Called send_key_command after disconnect")
|
|
self.keys_sent.append((key, direction))
|
|
|
|
def send_launch_app_command(self, app_link_or_app_id):
|
|
if not self._remote_message_protocol:
|
|
raise ConnectionClosed("Called send_launch_app_command after disconnect")
|
|
self.apps_launched.append(app_link_or_app_id)
|
|
|
|
def send_text(self, text):
|
|
if not self._remote_message_protocol:
|
|
raise ConnectionClosed("Called send_text after disconnect")
|
|
self.texts_sent.append(text)
|
|
|
|
def fire_invalid_auth(self):
|
|
if self._invalid_auth_callback is not None:
|
|
self._invalid_auth_callback()
|
|
|
|
def disconnect(self):
|
|
self.disconnect_calls += 1
|
|
if self._reconnect_task is not None:
|
|
self._reconnect_task.cancel()
|
|
|
|
|
|
def _protocol_manager(client=None, **kwargs) -> ShieldConnectionManager:
|
|
return ShieldConnectionManager(hass=None, entry=None, protocol=client, **kwargs)
|
|
|
|
|
|
async def _drain_loop():
|
|
"""Let call_soon_threadsafe callbacks scheduled via the running loop run."""
|
|
await asyncio.sleep(0.01)
|
|
|
|
|
|
async def _connect(manager, client, tries: int = 40):
|
|
"""Wait for the manager's background connect task to make its attempt (t15)."""
|
|
for _ in range(tries):
|
|
if client.connect_calls:
|
|
await _drain_loop()
|
|
return
|
|
await asyncio.sleep(0.005)
|
|
raise AssertionError("the manager never attempted to connect the protocol")
|
|
|
|
|
|
class TestPushCallbackWiring:
|
|
async def test_is_on_and_availability_push_into_store(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
notified = []
|
|
manager.register_listener(lambda: notified.append(True))
|
|
|
|
await manager._start_protocol()
|
|
client.push_is_on(True)
|
|
client.push_is_available(False)
|
|
await _drain_loop()
|
|
|
|
assert manager.is_on is True
|
|
assert manager.is_available is False
|
|
assert manager.store.get(FIELD_IS_ON) is True
|
|
assert notified, "listeners pinged from protocol pushes"
|
|
|
|
await manager.async_stop()
|
|
|
|
async def test_current_app_push_stored_raw_package(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
client.push_current_app("com.netflix.ninja")
|
|
await _drain_loop()
|
|
assert manager.current_app == "com.netflix.ninja" # name resolution deferred
|
|
await manager.async_stop()
|
|
|
|
async def test_volume_push_normalized_to_level_max_muted(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
client.push_volume_info({"level": 7, "max": 15, "muted": True})
|
|
await _drain_loop()
|
|
assert manager.volume_info == (7.0, 15.0, True)
|
|
assert manager.store.get(FIELD_VOLUME_INFO)[2] is True
|
|
await manager.async_stop()
|
|
|
|
|
|
class TestInvalidAuthHook:
|
|
async def test_invalid_auth_invokes_entry_callback(self):
|
|
client = FakeProtocolClient()
|
|
fired = []
|
|
manager = _protocol_manager(
|
|
client, invalid_auth_callback=lambda: fired.append(True)
|
|
)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client) # t15: a session first, then pushes/auth
|
|
|
|
client.fire_invalid_auth()
|
|
|
|
assert fired == [True], "reauth hook fired, not a retry storm (t9 delegates)"
|
|
await manager.async_stop()
|
|
|
|
async def test_invalid_auth_without_callback_is_safe(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
client.fire_invalid_auth() # no callback registered — must not raise
|
|
await manager.async_stop()
|
|
|
|
|
|
def _fast_backoff(manager, delay: float = 0.01) -> None:
|
|
"""Make the connect-retry backoff fast for tests (t15), per manager."""
|
|
manager._jitter = lambda _delay: delay
|
|
|
|
|
|
class TestInitialConnect:
|
|
"""t15: the transport must CONNECT, not merely be told to keep reconnecting.
|
|
|
|
The shipped bug: `_start_protocol` called `keep_reconnecting()` and nothing in
|
|
the tree ever called `async_connect()`. The library's reconnect task is
|
|
`while self._remote_message_protocol:` — None until a first connect — so the
|
|
task returned immediately: no session, no push, every send raising
|
|
ConnectionClosed inside a swallowed handler, for every install. These tests pin
|
|
the order (connect first) and the state it reports.
|
|
"""
|
|
|
|
async def test_connects_before_handing_over_to_keep_reconnecting(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
|
|
assert client.connect_calls == 1
|
|
assert client.keep_reconnecting_calls == 1
|
|
assert client._remote_message_protocol is not None, "a session exists now"
|
|
assert client._reconnect_task is not None, (
|
|
"keep_reconnecting only maintains a session; a task with no session "
|
|
"would be the pre-t15 no-op"
|
|
)
|
|
assert manager.protocol_state == "connected"
|
|
assert manager.protocol_connected is True
|
|
assert manager.protocol_ever_authenticated is True
|
|
assert manager.protocol_last_error is None
|
|
await manager.async_stop()
|
|
|
|
async def test_keep_reconnecting_alone_would_stay_unconnected(self):
|
|
"""The fake reproduces the library: without async_connect nothing happens."""
|
|
client = FakeProtocolClient()
|
|
client.keep_reconnecting(lambda: None)
|
|
await _drain_loop()
|
|
|
|
assert client._reconnect_task.done(), (
|
|
"the library's loop returns at once with no session — which is why "
|
|
"keep_reconnecting() alone never connected anything"
|
|
)
|
|
assert client.connect_calls == 0
|
|
|
|
async def test_network_failure_keeps_retrying_and_stays_never_connected(self):
|
|
client = FakeProtocolClient(connect_error=CannotConnect("unreachable"))
|
|
manager = _protocol_manager(client)
|
|
_fast_backoff(manager)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
|
|
assert manager.protocol_state == "never_connected"
|
|
assert manager.protocol_connected is False
|
|
assert "CannotConnect" in (manager.protocol_last_error or "")
|
|
assert client.keep_reconnecting_calls == 0, (
|
|
"nothing to maintain until a session exists"
|
|
)
|
|
|
|
for _ in range(200): # the retry loop must keep trying
|
|
if client.connect_calls >= 2:
|
|
break
|
|
await asyncio.sleep(0.005)
|
|
assert client.connect_calls >= 2, "a network failure is retried with backoff"
|
|
assert manager.protocol_ever_authenticated is False
|
|
await manager.async_stop()
|
|
|
|
async def test_auth_rejection_stops_and_runs_the_reauth_hook(self):
|
|
client = FakeProtocolClient(connect_error=InvalidAuth("Need to pair"))
|
|
fired = []
|
|
manager = _protocol_manager(
|
|
client, invalid_auth_callback=lambda: fired.append(True)
|
|
)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
|
|
assert manager.protocol_state == "auth_rejected"
|
|
assert fired == [True], "the entry's reauth/pair flow is the answer"
|
|
assert "InvalidAuth" in (manager.protocol_last_error or "")
|
|
assert client.keep_reconnecting_calls == 0
|
|
|
|
connect_calls = client.connect_calls
|
|
await asyncio.sleep(0.05)
|
|
assert client.connect_calls == connect_calls, (
|
|
"retrying a rejected certificate can never succeed"
|
|
)
|
|
await manager.async_stop()
|
|
|
|
async def test_auth_rejection_after_a_session_marks_the_state(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
assert manager.protocol_state == "connected"
|
|
|
|
client.fire_invalid_auth()
|
|
|
|
assert manager.protocol_state == "auth_rejected"
|
|
assert manager.protocol_last_error # readable, not only logged
|
|
await manager.async_stop()
|
|
|
|
|
|
class TestHonestSendFailures:
|
|
"""t15: a send that could not run must never look like one that did."""
|
|
|
|
async def test_send_without_a_session_warns_and_records(self, caplog):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
client._remote_message_protocol = None # session lost (library no-ops sends)
|
|
|
|
with caplog.at_level("WARNING"):
|
|
sent = await manager.send_protocol_key("POWER")
|
|
|
|
assert sent is False
|
|
assert manager.protocol_failures == 1
|
|
assert "POWER" in (manager.protocol_last_error or "")
|
|
assert "ConnectionClosed" in (manager.protocol_last_error or "")
|
|
assert any(
|
|
"did not run" in rec.message for rec in caplog.records
|
|
), "the failure is a WARNING carrying what happened"
|
|
assert client.keys_sent == []
|
|
await manager.async_stop()
|
|
|
|
async def test_launch_without_a_transport_warns_and_records(self, caplog):
|
|
manager = _protocol_manager(None) # protocol disabled for this entry
|
|
with caplog.at_level("WARNING"):
|
|
sent = await manager.launch_app("com.netflix.ninja")
|
|
|
|
assert sent is False
|
|
assert manager.protocol_failures == 1
|
|
assert "no protocol transport" in (manager.protocol_last_error or "")
|
|
assert any("did not run" in rec.message for rec in caplog.records)
|
|
await manager.async_stop()
|
|
|
|
async def test_a_working_send_is_not_counted_as_a_failure(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
|
|
assert await manager.send_protocol_key("POWER") is True
|
|
assert await manager.launch_app("com.netflix.ninja") is True
|
|
|
|
assert client.keys_sent == [("POWER", "SHORT")]
|
|
assert client.apps_launched == ["com.netflix.ninja"]
|
|
assert manager.protocol_failures == 0
|
|
assert manager.protocol_last_error is None
|
|
await manager.async_stop()
|
|
|
|
async def test_availability_push_drives_the_connected_state(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
client.push_is_available(False)
|
|
await _drain_loop()
|
|
assert manager.protocol_state == "disconnected"
|
|
assert manager.protocol_connected is False
|
|
|
|
client.push_is_available(True)
|
|
await _drain_loop()
|
|
assert manager.protocol_state == "connected"
|
|
await manager.async_stop()
|
|
|
|
async def test_a_push_proves_an_authenticated_session(self):
|
|
"""The library exposes no is_paired: a push is the only proof there is."""
|
|
client = FakeProtocolClient(connect_error=CannotConnect("down"))
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
assert manager.protocol_ever_authenticated is False
|
|
|
|
client.push_current_app("com.netflix.ninja")
|
|
await _drain_loop()
|
|
|
|
assert manager.protocol_ever_authenticated is True
|
|
await manager.async_stop()
|
|
|
|
async def test_transport_state_is_off_without_a_client(self):
|
|
manager = _protocol_manager(None)
|
|
await manager._start_protocol()
|
|
assert manager.protocol_state == "off"
|
|
assert manager.protocol_connected is False
|
|
assert manager.protocol_ever_authenticated is False
|
|
await manager.async_stop()
|
|
|
|
|
|
class TestLifecycle:
|
|
async def test_stop_disconnects_lib_owned_reconnect_task(self):
|
|
"""The library owns keep_reconnecting's task; closing the client
|
|
tears it down — the manager never wraps that task itself."""
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
task = client._reconnect_task
|
|
assert task is not None
|
|
|
|
await manager.async_stop()
|
|
await _drain_loop() # let the lib-owned task settle its cancellation
|
|
|
|
assert client.disconnect_calls == 1
|
|
assert task.cancelled()
|
|
assert manager._protocol is None
|
|
|
|
async def test_protocol_with_factory_settings_builds_client(self, monkeypatch):
|
|
client = FakeProtocolClient()
|
|
built = []
|
|
|
|
def fake_builder(*args, **kwargs):
|
|
built.append(kwargs)
|
|
return client
|
|
|
|
monkeypatch.setattr(ShieldConnectionManager, "build_protocol_client", fake_builder)
|
|
manager = ShieldConnectionManager(
|
|
hass=None,
|
|
entry=None,
|
|
protocol_settings={
|
|
"host": "shield.local",
|
|
"client_name": "Home Assistant",
|
|
"certfile": "/tmp/cert.pem",
|
|
"keyfile": "/tmp/key.pem",
|
|
},
|
|
)
|
|
await manager._start_protocol()
|
|
|
|
assert manager._protocol is client
|
|
assert built
|
|
kwargs = built[0]
|
|
assert kwargs["host"] == "shield.local"
|
|
assert kwargs["client_name"] == "Home Assistant"
|
|
assert kwargs["certfile"] == "/tmp/cert.pem"
|
|
assert kwargs["keyfile"] == "/tmp/key.pem"
|
|
await manager.async_stop()
|
|
|
|
async def test_passive_without_protocol_configuration(self):
|
|
manager = _protocol_manager(client=None)
|
|
await manager._start_protocol() # no factory, no settings → no-op
|
|
|
|
assert manager._protocol is None
|
|
assert manager.is_on is None
|
|
assert manager.is_available is None
|
|
assert manager.current_app is None
|
|
assert manager.volume_info is None
|
|
await manager.async_stop()
|
|
|
|
|
|
class TestPushEdgeCases:
|
|
async def test_push_after_loop_close_falls_back_to_direct_notify(self):
|
|
"""Push callbacks that outlive a closed loop must not crash."""
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
notified = []
|
|
manager.register_listener(lambda: notified.append(True))
|
|
|
|
await manager._start_protocol()
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.close()
|
|
manager._protocol_loop = loop # stale/closed loop (mid-shutdown state)
|
|
|
|
client.push_is_on(True)
|
|
await _drain_loop()
|
|
|
|
assert manager.is_on is True # store stamped via direct-notify path
|
|
assert notified # listeners still pinged synchronously
|
|
await manager.async_stop()
|
|
|
|
async def test_store_ttl_applies_to_protocol_fields(self):
|
|
now = [1000.0]
|
|
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client, store=store)
|
|
await manager._start_protocol()
|
|
client.push_is_on(True)
|
|
await _drain_loop()
|
|
|
|
now[0] += 60.1
|
|
assert manager.is_on is None # expired like every other store field
|
|
assert manager.store.get(FIELD_IS_AVAILABLE) is None
|
|
await manager.async_stop()
|
|
|
|
async def test_pushes_are_stamped_with_transition_time(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
before = time.monotonic()
|
|
client.push_is_available(True)
|
|
await _drain_loop()
|
|
after = time.monotonic()
|
|
stamped_at = manager.store._values[FIELD_IS_AVAILABLE][0]
|
|
assert before <= stamped_at <= after
|
|
await manager.async_stop()
|
|
|
|
|
|
class FakeAdbForOutage:
|
|
"""ADB poller stub for the outage test (wake poll fails)."""
|
|
|
|
def __init__(self):
|
|
self.connected = True
|
|
self.awake = None
|
|
self.disconnect_calls = 0
|
|
|
|
async def connect(self):
|
|
self.connected = True
|
|
return True
|
|
|
|
async def disconnect(self):
|
|
self.disconnect_calls += 1
|
|
self.connected = False
|
|
|
|
async def is_awake(self):
|
|
return self.awake if self.connected else None
|
|
|
|
|
|
class TestAdbOutageKeepsProtocolTruth:
|
|
async def test_note_failure_clears_only_adb_fields(self):
|
|
"""F2: an ADB outage must not clobber device-reported protocol truth."""
|
|
poller = FakeAdbForOutage()
|
|
poller.connected = True
|
|
poller.awake = None # wake poll fails → cycle failure path
|
|
client = FakeProtocolClient()
|
|
manager = ShieldConnectionManager(
|
|
hass=None, entry=None, poller=poller, protocol=client
|
|
)
|
|
await manager._start_protocol()
|
|
|
|
client.push_is_on(True)
|
|
client.push_is_available(True)
|
|
client.push_current_app("com.netflix.ninja")
|
|
client.push_volume_info({"level": 5, "max": 15, "muted": False})
|
|
manager.store.set(FIELD_AWAKE, True)
|
|
manager.store.set(FIELD_SESSIONS, ["session-data"])
|
|
|
|
await manager._watchdog_cycle(5.0) # ADB outage → _note_failure
|
|
|
|
# ADB-derived fields cleared (regenerated by the watchdog)
|
|
assert manager.store.get(FIELD_AWAKE) is None
|
|
assert manager.store.get(FIELD_SESSIONS, []) == []
|
|
# protocol push truth survives the ADB outage (TTL expiry rules it)
|
|
assert manager.is_on is True
|
|
assert manager.is_available is True
|
|
assert manager.current_app == "com.netflix.ninja"
|
|
assert manager.volume_info == (5.0, 15.0, False)
|
|
await manager.async_stop()
|
|
|
|
|
|
class TestAdbStaysThePrimarySource:
|
|
"""t15: arming the protocol must not promote its pushes over ADB's readings.
|
|
|
|
The pushes this unit turns ON (is_on / is_available / current_app / volume) had
|
|
NEVER been live here — ``keep_reconnecting`` alone never connected — so the whole
|
|
protocol surface is new traffic next to an ADB media-session poller the user
|
|
relies on. The store's fields are disjoint by design; these tests pin that, and
|
|
that an ADB-only entry keeps working with only the protocol surface degraded.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _adbreath(manager):
|
|
"""Every field the ADB poller owns, stamped, and read back."""
|
|
manager.store.set(FIELD_AWAKE, True)
|
|
manager.store.set(FIELD_SESSIONS, ["session-data"])
|
|
manager.store.set(FIELD_AUDIO_PLAYBACK, ["com.audio.last"])
|
|
manager.store.set(FIELD_RUNNING_APPS, [{"package": "com.run.now"}])
|
|
manager.store.set(FIELD_FOREGROUND, "com.adb.foreground")
|
|
return {
|
|
field: manager.store.get(field)
|
|
for field in (
|
|
FIELD_AWAKE,
|
|
FIELD_SESSIONS,
|
|
FIELD_AUDIO_PLAYBACK,
|
|
FIELD_RUNNING_APPS,
|
|
FIELD_FOREGROUND,
|
|
)
|
|
}
|
|
|
|
async def test_a_protocol_push_never_overwrites_an_adb_reading(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
before = self._adbreath(manager)
|
|
|
|
client.push_is_on(True)
|
|
client.push_is_available(True)
|
|
client.push_current_app("com.protocol.foreground")
|
|
client.push_volume_info({"level": 5, "max": 15, "muted": False})
|
|
await _drain_loop()
|
|
|
|
assert {
|
|
field: manager.store.get(field) for field in before
|
|
} == before, "ADB's own fields are untouched by every protocol push"
|
|
# ...and the protocol wrote exactly its own four
|
|
assert manager.is_on is True
|
|
assert manager.is_available is True
|
|
assert manager.current_app == "com.protocol.foreground"
|
|
assert manager.volume_info == (5.0, 15.0, False)
|
|
await manager.async_stop()
|
|
|
|
async def test_the_foreground_identity_falls_back_to_adb(self):
|
|
"""No push yet: the ADB parse answers, and nothing is invented."""
|
|
client = FakeProtocolClient(connect_error=CannotConnect("down"))
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
self._adbreath(manager)
|
|
|
|
assert manager.current_app is None, "no protocol push ever arrived"
|
|
assert manager.foreground_app == "com.adb.foreground"
|
|
assert manager.foreground_source == "adb", "the tier is named, not implied"
|
|
await manager.async_stop()
|
|
|
|
async def test_a_push_wins_while_fresh_and_the_adb_reading_survives_it(self):
|
|
"""The documented priority, and no loss: an expired push re-exposes ADB."""
|
|
now = [1000.0]
|
|
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client, store=store)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
self._adbreath(manager)
|
|
client.push_current_app("com.protocol.foreground")
|
|
await _drain_loop()
|
|
|
|
assert manager.foreground_app == "com.protocol.foreground"
|
|
assert manager.foreground_source == "protocol"
|
|
assert manager.store.get(FIELD_FOREGROUND) == "com.adb.foreground", (
|
|
"the push wrote its OWN field — the ADB reading was not clobbered"
|
|
)
|
|
|
|
now[0] += 60.1 # both fields expire on the store's own TTL, not by a push
|
|
|
|
assert manager.current_app is None
|
|
assert manager.store.get(FIELD_FOREGROUND) is None
|
|
# re-stamped (the next ADB poll), the ADB reading answers again untouched
|
|
manager.store.set(FIELD_FOREGROUND, "com.adb.foreground")
|
|
assert manager.foreground_app == "com.adb.foreground"
|
|
assert manager.foreground_source == "adb"
|
|
await manager.async_stop()
|
|
|
|
async def test_an_adb_only_entry_keeps_working(self):
|
|
"""No protocol client at all: ADB truth refreshes, only the transport degrades."""
|
|
poller = FakeAdbForOutage()
|
|
poller.connected = True
|
|
poller.awake = True
|
|
manager = ShieldConnectionManager(hass=None, entry=None, poller=poller)
|
|
await manager._start_protocol()
|
|
|
|
assert manager._protocol is None
|
|
assert manager.protocol_state == "off"
|
|
assert manager.protocol_connected is False
|
|
assert manager.protocol_ever_authenticated is False
|
|
|
|
await manager.async_stop()
|
|
|
|
|
|
class TestImportGuard:
|
|
def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog):
|
|
"""F1: a protocol-enabled entry without androidtvremote2 keeps
|
|
running ADB-only — the guarded factory logs a warning and builds
|
|
nothing instead of raising at setup."""
|
|
import logging
|
|
|
|
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
|
|
with caplog.at_level(logging.WARNING):
|
|
client = ShieldConnectionManager.build_protocol_client(
|
|
"shield.local", "Home Assistant", "/tmp/c.pem", "/tmp/k.pem"
|
|
)
|
|
assert client is None
|
|
assert any("degrades to ADB-only" in r.message for r in caplog.records)
|
|
|
|
async def test_settings_entry_without_library_stays_passive(self, monkeypatch):
|
|
manager = ShieldConnectionManager(
|
|
hass=None,
|
|
entry=None,
|
|
protocol_settings={
|
|
"host": "shield.local",
|
|
"client_name": "HA",
|
|
"certfile": "/tmp/c.pem",
|
|
"keyfile": "/tmp/k.pem",
|
|
},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
|
|
await manager._start_protocol()
|
|
assert manager._protocol is None # passive, no crash
|
|
await manager.async_stop()
|
|
|
|
|
|
class TestVolumeTuple:
|
|
def test_normalizes_typed_dict(self):
|
|
assert volume_tuple({"level": 7, "max": 15, "muted": True}) == (7.0, 15.0, True)
|
|
|
|
def test_defaults_on_partial_dict(self):
|
|
assert volume_tuple({"muted": False}) == (0.0, 1.0, False)
|
|
|
|
def test_none_and_garbage_yield_none(self):
|
|
assert volume_tuple(None) is None
|
|
assert volume_tuple("garbage") is None
|
|
|
|
def test_accepts_real_library_volume_info(self):
|
|
androidtvremote2 = pytest.importorskip("androidtvremote2")
|
|
info = androidtvremote2.VolumeInfo(level=3, max=15, muted=True)
|
|
assert volume_tuple(info) == (3.0, 15.0, True)
|
|
|
|
|
|
# ── t20: the t19 review's protocol findings ──
|
|
|
|
|
|
class HangingProtocolClient(FakeProtocolClient):
|
|
"""``async_connect()`` that never returns until the test says so (t20).
|
|
|
|
The lifecycle findings are about what happens WHILE an attempt is in flight,
|
|
so the fake has to be able to sit inside ``async_connect``: it parks on an
|
|
event, records how many times it was cancelled, and only then - if it is ever
|
|
released - becomes a session.
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.entered = asyncio.Event()
|
|
self.release = asyncio.Event()
|
|
self.cancelled = 0
|
|
|
|
async def async_connect(self):
|
|
self.connect_calls += 1
|
|
self.entered.set()
|
|
try:
|
|
await self.release.wait()
|
|
except asyncio.CancelledError:
|
|
self.cancelled += 1
|
|
raise
|
|
self._remote_message_protocol = object()
|
|
|
|
|
|
class OnceFailingProtocolClient(FakeProtocolClient):
|
|
"""Fails its first attempts, then connects - the RECOVERY sequence (t20/O1)."""
|
|
|
|
def __init__(self, failures: int = 1, error: Exception | None = None):
|
|
super().__init__()
|
|
self._failures_left = failures
|
|
self._error = error or CannotConnect("Net: boom")
|
|
|
|
async def async_connect(self):
|
|
self.connect_calls += 1
|
|
if self._failures_left > 0:
|
|
self._failures_left -= 1
|
|
raise self._error
|
|
self._remote_message_protocol = object()
|
|
|
|
|
|
class TestRecoveredErrorsAreNotStale:
|
|
"""t20/O1: the published pair can never contradict itself.
|
|
|
|
``protocol_state``/``protocol_connected`` is the transport's CURRENT
|
|
condition; ``protocol_last_error`` is the failure the CURRENT state has not
|
|
recovered from. It is cleared whenever a session becomes proven and kept while
|
|
the transport is still down (and, deliberately, kept for a failure that
|
|
happened AFTER the session came up - that one is current, not stale).
|
|
"""
|
|
|
|
async def test_failure_then_recovery_publishes_no_stale_error(self):
|
|
"""The measured defect: 'connect: Net: boom' beside connected: true."""
|
|
client = OnceFailingProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
_fast_backoff(manager)
|
|
await manager._start_protocol()
|
|
|
|
for _ in range(200): # the failure is recorded while it is still down
|
|
if manager.protocol_last_error:
|
|
break
|
|
await asyncio.sleep(0.005)
|
|
assert manager.protocol_state == "never_connected"
|
|
assert manager.protocol_connected is False
|
|
assert "CannotConnect" in (manager.protocol_last_error or "")
|
|
|
|
for _ in range(400): # the retry succeeds
|
|
if manager.protocol_connected:
|
|
break
|
|
await asyncio.sleep(0.005)
|
|
|
|
assert manager.protocol_connected is True
|
|
assert manager.protocol_last_error is None, (
|
|
"a recovered failure must not be published beside connected: true"
|
|
)
|
|
assert manager.protocol_failures == 0, "a connect failure is not a SEND failure"
|
|
await manager.async_stop()
|
|
|
|
async def test_failure_then_still_disconnected_keeps_the_error(self):
|
|
"""The other direction: a failure that still describes the transport stays."""
|
|
client = FakeProtocolClient(connect_error=CannotConnect("Net: boom"))
|
|
manager = _protocol_manager(client)
|
|
manager._jitter = lambda delay: 60.0 # one attempt, then sleep past the test
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
|
|
assert manager.protocol_state == "never_connected"
|
|
assert manager.protocol_connected is False
|
|
assert "Net: boom" in (manager.protocol_last_error or "")
|
|
await manager.async_stop()
|
|
|
|
async def test_a_send_failure_before_a_session_is_cleared_by_the_connect(self):
|
|
"""The review's exact sequence, driven through public calls."""
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
|
|
assert await manager.send_protocol_key("POWER") is False # no session yet
|
|
assert manager.protocol_last_error, "the current failure is recorded"
|
|
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
|
|
assert manager.protocol_connected is True
|
|
assert manager.protocol_last_error is None, "recovery clears it"
|
|
assert manager.protocol_failures == 1, "history stays in the counter"
|
|
await manager.async_stop()
|
|
|
|
async def test_an_availability_push_also_clears_a_recovered_error(self):
|
|
"""A push is the OTHER proof of a live session, so it clears it too."""
|
|
client = FakeProtocolClient(connect_error=CannotConnect("Net: boom"))
|
|
manager = _protocol_manager(client)
|
|
manager._jitter = lambda delay: 60.0 # the retry cannot race this assertion
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
assert manager.protocol_last_error
|
|
|
|
client.push_is_available(True)
|
|
await _drain_loop()
|
|
|
|
assert manager.protocol_state == "connected"
|
|
assert manager.protocol_last_error is None
|
|
await manager.async_stop()
|
|
|
|
async def test_a_send_failure_after_the_session_came_up_is_current(self):
|
|
"""Not the same thing as stale: the session failed just now."""
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
assert manager.protocol_last_error is None
|
|
|
|
client._remote_message_protocol = None # the session is gone
|
|
assert await manager.send_protocol_key("POWER") is False
|
|
assert "POWER" in (manager.protocol_last_error or "")
|
|
|
|
# ...and a session proven again clears it
|
|
client._remote_message_protocol = object()
|
|
client.push_is_available(True)
|
|
await _drain_loop()
|
|
assert manager.protocol_last_error is None
|
|
await manager.async_stop()
|
|
|
|
async def test_recovery_notifies_listeners_because_a_published_value_changed(self):
|
|
"""The error is an attribute: clearing it is a state change to publish."""
|
|
manager = _protocol_manager(FakeProtocolClient())
|
|
notified = []
|
|
manager.register_listener(lambda: notified.append(manager.protocol_last_error))
|
|
|
|
await manager.send_protocol_key("POWER") # records an error
|
|
assert manager.protocol_last_error
|
|
before = len(notified)
|
|
|
|
manager._note_protocol_state(PROTOCOL_CONNECTED, None)
|
|
|
|
assert len(notified) > before, "listeners were told the error went away"
|
|
assert manager.protocol_last_error is None
|
|
|
|
|
|
class TestConnectAttemptLifecycle:
|
|
"""t20/O4 + O5: the connect attempt is tracked, and a second start is defined.
|
|
|
|
Both findings interact with the options UI: every save reloads the entry, so an
|
|
attempt that survives its entry (or a start that orphans an earlier one) can
|
|
accumulate reconnect loops across saves.
|
|
"""
|
|
|
|
async def test_unload_during_a_connect_attempt_cancels_and_awaits_it(self):
|
|
"""The proof for O4: no live task, and no further connect attempts."""
|
|
client = HangingProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
_fast_backoff(manager)
|
|
await manager._start_protocol()
|
|
await asyncio.wait_for(client.entered.wait(), 1)
|
|
|
|
task = manager._protocol_connect_task
|
|
assert task is not None and not task.done()
|
|
assert task in manager._tasks, "tracked, so unload can cancel it (O4)"
|
|
assert client.connect_calls == 1
|
|
|
|
await manager.async_stop()
|
|
|
|
assert task.done(), "the unload AWAITED the attempt"
|
|
assert task.cancelled()
|
|
assert client.cancelled == 1, "the in-flight connect was cancelled"
|
|
assert manager._tasks == [], "nothing is left to leak into the next reload"
|
|
|
|
calls = client.connect_calls
|
|
for _ in range(20):
|
|
await asyncio.sleep(0.01)
|
|
assert client.connect_calls == calls, "no attempt survives the entry"
|
|
|
|
async def test_async_start_tracks_every_loop_and_stop_reaps_them(self):
|
|
client = HangingProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
_fast_backoff(manager)
|
|
|
|
await manager.async_start() # the watchdog (passive here) + the connect
|
|
await asyncio.wait_for(client.entered.wait(), 1)
|
|
tracked = list(manager._tasks)
|
|
assert len(tracked) == 2, [t.get_name() for t in tracked]
|
|
|
|
await manager.async_stop()
|
|
|
|
assert manager._tasks == []
|
|
assert all(task.done() for task in tracked), "both were awaited"
|
|
|
|
async def test_a_second_start_reuses_the_live_attempt(self):
|
|
"""The proof for O5: starting twice leaves exactly ONE live attempt."""
|
|
client = HangingProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await asyncio.wait_for(client.entered.wait(), 1)
|
|
first = manager._protocol_connect_task
|
|
|
|
await manager._start_protocol()
|
|
|
|
assert manager._protocol_connect_task is first, "reused, never orphaned"
|
|
assert client.connect_calls == 1, "exactly one attempt was made"
|
|
assert [t for t in manager._tasks if not t.done()] == [first]
|
|
assert manager._tasks.count(first) == 1, "registered once, not once per start"
|
|
assert client._is_on_callbacks == [manager._on_is_on], (
|
|
"the push callbacks are not registered twice either"
|
|
)
|
|
await manager.async_stop()
|
|
|
|
async def test_a_second_start_after_a_session_does_not_build_a_second_loop(self):
|
|
"""The library owns the session; a second keep_reconnecting would be a leak."""
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
first = manager._protocol_connect_task
|
|
assert manager.protocol_connected is True
|
|
|
|
await manager._start_protocol()
|
|
|
|
assert manager._protocol_connect_task is first
|
|
assert client.connect_calls == 1
|
|
assert client.keep_reconnecting_calls == 1, "one owner for the session"
|
|
await manager.async_stop()
|
|
|
|
async def test_a_second_start_after_a_terminal_failure_tries_again(self):
|
|
"""Deterministic in BOTH directions: a finished attempt may be replaced."""
|
|
client = FakeProtocolClient(connect_error=InvalidAuth("Need to pair"))
|
|
manager = _protocol_manager(client, invalid_auth_callback=lambda: None)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
first = manager._protocol_connect_task
|
|
assert first.done(), "the auth path is terminal: the loop returned"
|
|
assert manager.protocol_state == "auth_rejected"
|
|
|
|
await manager._start_protocol()
|
|
|
|
assert manager._protocol_connect_task is not first, "a FRESH attempt replaces it"
|
|
for _ in range(200): # the fresh task needs a turn to make its attempt
|
|
if client.connect_calls == 2:
|
|
break
|
|
await asyncio.sleep(0.005)
|
|
assert client.connect_calls == 2, "a restart really retries"
|
|
await manager.async_stop()
|
|
|
|
async def test_a_stopped_manager_is_not_restarted_by_a_late_call(self):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager.async_stop()
|
|
|
|
await manager._start_protocol()
|
|
|
|
assert manager._protocol_connect_task is None
|
|
assert manager._tasks == []
|
|
|
|
|
|
class TestTransportBannersAreNotStale:
|
|
"""t20/O2: define stale, then make it impossible.
|
|
|
|
A STALE banner is one whose transport claim a later state change never followed
|
|
up on, so the log's last word contradicts the entity. The rules that remove it:
|
|
the affirmative banner states only what the attempt proved, and EVERY state
|
|
change announces itself with both the old and the new state.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _state_lines(records):
|
|
return [r for r in records if "transport state:" in r.getMessage()]
|
|
|
|
async def test_the_connected_banner_claims_no_liveliness(self, caplog):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
|
|
with caplog.at_level("INFO"):
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
|
|
opened = [r for r in caplog.records if "session opened" in r.getMessage()]
|
|
assert opened, "connecting is announced"
|
|
text = opened[0].getMessage()
|
|
assert "what this proves" in text, "it names its own limit"
|
|
assert "availability push" in text, "and who maintains the session from there"
|
|
assert "paired" not in text.lower(), "a connect attempt cannot prove pairing"
|
|
|
|
lines = self._state_lines(caplog.records)
|
|
assert lines, "the transition is announced"
|
|
assert lines[-1].getMessage().endswith(manager.protocol_state)
|
|
await manager.async_stop()
|
|
|
|
async def test_a_later_state_change_announces_itself(self, caplog):
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
|
|
with caplog.at_level("INFO"):
|
|
client.push_is_available(False) # the device reports the session down
|
|
await _drain_loop()
|
|
|
|
lines = self._state_lines(caplog.records)
|
|
assert lines, "a session going down is announced"
|
|
assert lines[-1].getMessage().endswith("disconnected")
|
|
assert manager.protocol_state == "disconnected", "the banner matches the entity"
|
|
await manager.async_stop()
|
|
|
|
async def test_the_open_banner_and_the_transition_agree(self, caplog):
|
|
"""No two lines about the same moment may name different states."""
|
|
client = FakeProtocolClient()
|
|
manager = _protocol_manager(client)
|
|
|
|
with caplog.at_level("INFO"):
|
|
await manager._start_protocol()
|
|
await _connect(manager, client)
|
|
|
|
opened = [r for r in caplog.records if "session opened" in r.getMessage()]
|
|
lines = self._state_lines(caplog.records)
|
|
assert opened and lines
|
|
assert "connected" in opened[0].getMessage()
|
|
assert lines[-1].getMessage().endswith("connected")
|
|
await manager.async_stop()
|