feat(adb_client): exact-value mWakefulness= parse (R5), health_check(), optional media-session truncation (B4); wakefulness fixtures + tests
This commit is contained in:
1 parent
f6a37bdcd5
commit
b91a4242ba
4 files changed
+209
-10
No files matched your search
@@ -5,6 +5,7 @@ Subclasses add device-specific polling (media sessions, wake state, etc.).
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
||||||
|
|
||||||
@@ -31,6 +32,33 @@ DEVICE_KEYCODES = {
|
|||||||
|
|
||||||
ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES}
|
ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES}
|
||||||
|
|
||||||
|
# Recognized non-awake mWakefulness= values (see Android PowerManagerInternal).
|
||||||
|
# Anything else is "unknown" — never silently reported as awake or asleep.
|
||||||
|
_WAKEFULNESS_ASLEEP_VALUES = {"Asleep", "Dozing", "Dozing-suspend"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_wakefulness(text: str | None) -> bool | None:
|
||||||
|
"""Parse `dumpsys power` output for the exact ``mWakefulness=`` value (R5).
|
||||||
|
|
||||||
|
Exact-value parse, no substring matching: only the ``mWakefulness=``
|
||||||
|
assignment counts — ``mWakefulnessChanging=false`` and
|
||||||
|
``mWakefulnessSetting=...`` are ignored. Unknown values (e.g. a future
|
||||||
|
firmware's ``mWakefulness=Dozing-ext``) return None so callers treat it
|
||||||
|
as "unknown", never as awake or asleep.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
for line in text.splitlines():
|
||||||
|
match = re.search(r"(?<![A-Za-z])mWakefulness=([^\s,}]+)", line)
|
||||||
|
if match:
|
||||||
|
value = match.group(1)
|
||||||
|
if value == "Awake":
|
||||||
|
return True
|
||||||
|
if value in _WAKEFULNESS_ASLEEP_VALUES:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class AdbClientBase:
|
class AdbClientBase:
|
||||||
"""Base ADB client with connection management and common commands."""
|
"""Base ADB client with connection management and common commands."""
|
||||||
@@ -143,27 +171,50 @@ class AdbClientBase:
|
|||||||
return result is not None
|
return result is not None
|
||||||
|
|
||||||
async def is_awake(self) -> bool | None:
|
async def is_awake(self) -> bool | None:
|
||||||
"""Check if the device screen is on / awake via ADB."""
|
"""Check if the device screen is on / awake via ADB.
|
||||||
|
|
||||||
|
Exact-value mWakefulness= parse (R5); None means "unknown".
|
||||||
|
"""
|
||||||
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
|
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
|
||||||
if result:
|
return parse_wakefulness(result)
|
||||||
return "Awake" in result
|
|
||||||
return None
|
|
||||||
|
|
||||||
# -- Media session polling --
|
# -- Media session polling --
|
||||||
|
|
||||||
async def poll_media_sessions(
|
async def poll_media_sessions(
|
||||||
self, foreground_package: str | None = None, head_lines: int = 200
|
self, foreground_package: str | None = None, head_lines: int | None = 200
|
||||||
) -> MediaSessionInfo | None:
|
) -> MediaSessionInfo | None:
|
||||||
"""Query media sessions. Stores all sessions and returns the best match."""
|
"""Query media sessions. Stores all sessions and returns the best match.
|
||||||
dump = await self.shell(
|
|
||||||
f"dumpsys media_session 2>/dev/null | head -{head_lines}"
|
Args:
|
||||||
)
|
foreground_package: Prefer the session of the foreground app.
|
||||||
|
head_lines: Truncate the dump to N lines (the historical default
|
||||||
|
of 200). Pass None for the full dump — the ShieldConnection
|
||||||
|
manager does so, since the cut silently drops sessions below
|
||||||
|
it (plan blocker B4).
|
||||||
|
"""
|
||||||
|
command = "dumpsys media_session 2>/dev/null"
|
||||||
|
if head_lines is not None:
|
||||||
|
command += f" | head -{head_lines}"
|
||||||
|
dump = await self.shell(command)
|
||||||
if dump is None:
|
if dump is None:
|
||||||
return None
|
return None
|
||||||
self._all_sessions = parse_all_media_sessions(dump)
|
self._all_sessions = parse_all_media_sessions(dump)
|
||||||
self._last_info = parse_media_session(dump, foreground_package)
|
self._last_info = parse_media_session(dump, foreground_package)
|
||||||
return self._last_info
|
return self._last_info
|
||||||
|
|
||||||
|
# -- Health check --
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Verify the connection is actually usable with a cheap shell round trip.
|
||||||
|
|
||||||
|
Returns True only when the device answers the trivial ``echo ok`` with
|
||||||
|
the expected body — a stronger signal than the ``connected`` flag,
|
||||||
|
which can lag behind reality (the watchdog uses this to fire a
|
||||||
|
reconnect instead of trusting a dead socket).
|
||||||
|
"""
|
||||||
|
result = await self.shell("echo ok")
|
||||||
|
return result is not None and result.strip() == "ok"
|
||||||
|
|
||||||
# -- Running apps (recent visible tasks) --
|
# -- Running apps (recent visible tasks) --
|
||||||
|
|
||||||
async def poll_running_apps(self) -> list[dict]:
|
async def poll_running_apps(self) -> list[dict]:
|
||||||
@@ -180,7 +231,6 @@ class AdbClientBase:
|
|||||||
if not result:
|
if not result:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
import re
|
|
||||||
apps = []
|
apps = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mWakefulness=Asleep
|
||||||
|
mWakefulnessChanging=false
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mWakefulness=Awake
|
||||||
|
mWakefulnessChanging=false
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Tests for AdbClientBase additions: exact wakefulness parse (R5),
|
||||||
|
health_check(), and optional media-session truncation (plan blocker B4).
|
||||||
|
|
||||||
|
The client runs against a FakeDevice recording shell commands — no real ADB
|
||||||
|
socket or adb_shell import is needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ha_adb_common.adb_client import AdbClientBase, parse_wakefulness
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
CMD_WAKEFULNESS = "dumpsys power 2>/dev/null | grep mWakefulness"
|
||||||
|
CMD_MEDIA_DEFAULT = "dumpsys media_session 2>/dev/null | head -200"
|
||||||
|
CMD_MEDIA_FULL = "dumpsys media_session 2>/dev/null"
|
||||||
|
CMD_ECHO_OK = "echo ok"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDevice:
|
||||||
|
"""Records shell commands and returns canned responses per command."""
|
||||||
|
|
||||||
|
def __init__(self, responses=None):
|
||||||
|
self._responses = dict(responses or {})
|
||||||
|
self.commands: list[str] = []
|
||||||
|
|
||||||
|
async def shell(self, cmd: str) -> str | None:
|
||||||
|
self.commands.append(cmd)
|
||||||
|
response = self._responses.get(cmd)
|
||||||
|
if isinstance(response, Exception):
|
||||||
|
raise response
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _client_response(cmd: str, response: str | None) -> AdbClientBase:
|
||||||
|
"""A client whose (fake) device is already connected and answers cmd."""
|
||||||
|
client = AdbClientBase("shield.local", 5555, "/dev/null/adbkey")
|
||||||
|
client._device = FakeDevice({cmd: response})
|
||||||
|
client._connected = True # skip connect(): no adb_shell in the test env
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseWakefulness:
|
||||||
|
"""Exact-value mWakefulness= parse; mWakefulnessChanging excluded."""
|
||||||
|
|
||||||
|
def test_awake_fixture(self):
|
||||||
|
text = (FIXTURES / "dumpsys_power_wakefulness_awake.txt").read_text()
|
||||||
|
assert parse_wakefulness(text) is True
|
||||||
|
|
||||||
|
def test_asleep_fixture(self):
|
||||||
|
text = (FIXTURES / "dumpsys_power_wakefulness_asleep.txt").read_text()
|
||||||
|
assert parse_wakefulness(text) is False
|
||||||
|
|
||||||
|
def test_known_asleep_values(self):
|
||||||
|
assert parse_wakefulness("mWakefulness=Asleep") is False
|
||||||
|
assert parse_wakefulness("mWakefulness=Dozing") is False
|
||||||
|
assert parse_wakefulness("mWakefulness=Dozing-suspend") is False
|
||||||
|
|
||||||
|
def test_unknown_value_is_unknown_not_awake(self):
|
||||||
|
"""The old substring heuristic read any future value containing no
|
||||||
|
'Awake' as asleep; unrecognized values must be None (captain's note)."""
|
||||||
|
assert parse_wakefulness("mWakefulness=Dozing-ext") is None
|
||||||
|
assert parse_wakefulness("mWakefulness=123") is None
|
||||||
|
|
||||||
|
def test_changing_and_setting_lines_are_ignored(self):
|
||||||
|
assert (
|
||||||
|
parse_wakefulness(" mWakefulnessChanging=false") is None
|
||||||
|
)
|
||||||
|
assert parse_wakefulness(" mWakefulnessSetting=2") is None
|
||||||
|
|
||||||
|
def test_assignment_wins_over_changing_line(self):
|
||||||
|
text = " mWakefulnessChanging=false\nmWakefulness=Awake\n"
|
||||||
|
assert parse_wakefulness(text) is True
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
assert parse_wakefulness("") is None
|
||||||
|
assert parse_wakefulness(None) is None
|
||||||
|
assert parse_wakefulness("no match here") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsAwake:
|
||||||
|
async def test_awake(self):
|
||||||
|
client = _client_response(
|
||||||
|
CMD_WAKEFULNESS,
|
||||||
|
(FIXTURES / "dumpsys_power_wakefulness_awake.txt").read_text(),
|
||||||
|
)
|
||||||
|
assert await client.is_awake() is True
|
||||||
|
assert client._device.commands == [CMD_WAKEFULNESS]
|
||||||
|
|
||||||
|
async def test_asleep(self):
|
||||||
|
client = _client_response(
|
||||||
|
CMD_WAKEFULNESS,
|
||||||
|
(FIXTURES / "dumpsys_power_wakefulness_asleep.txt").read_text(),
|
||||||
|
)
|
||||||
|
assert await client.is_awake() is False
|
||||||
|
|
||||||
|
async def test_unknown_reports_none(self):
|
||||||
|
"""A garbage/empty grep result parses to None, not False."""
|
||||||
|
client = _client_response(CMD_WAKEFULNESS, "")
|
||||||
|
assert await client.is_awake() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthCheck:
|
||||||
|
async def test_echo_ok_round_trip_is_healthy(self):
|
||||||
|
client = _client_response(CMD_ECHO_OK, "ok\n")
|
||||||
|
assert await client.health_check() is True
|
||||||
|
|
||||||
|
async def test_failed_shell_is_unhealthy(self):
|
||||||
|
client = _client_response(CMD_ECHO_OK, Exception("socket closed"))
|
||||||
|
assert await client.health_check() is False
|
||||||
|
assert client.connected is False # shell() marked the socket broken
|
||||||
|
|
||||||
|
async def test_wrong_body_is_unhealthy(self):
|
||||||
|
client = _client_response(CMD_ECHO_OK, "something else\n")
|
||||||
|
assert await client.health_check() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollMediaSessionsTruncation:
|
||||||
|
async def test_default_truncates_at_200(self):
|
||||||
|
client = _client_response(CMD_MEDIA_DEFAULT, "")
|
||||||
|
assert await client.poll_media_sessions() is None
|
||||||
|
assert client._device.commands == [CMD_MEDIA_DEFAULT]
|
||||||
|
|
||||||
|
async def test_explicit_head_lines(self):
|
||||||
|
client = _client_response("dumpsys media_session 2>/dev/null | head -80", "")
|
||||||
|
await client.poll_media_sessions(head_lines=80)
|
||||||
|
assert client._device.commands == [
|
||||||
|
"dumpsys media_session 2>/dev/null | head -80"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_none_head_lines_fetches_full_dump(self):
|
||||||
|
"""None = no truncation — the manager later passes None (B4)."""
|
||||||
|
wholphin = (FIXTURES / "media_session_wholphin.txt").read_text()
|
||||||
|
client = _client_response(CMD_MEDIA_FULL, wholphin)
|
||||||
|
info = await client.poll_media_sessions(head_lines=None)
|
||||||
|
assert client._device.commands == [CMD_MEDIA_FULL]
|
||||||
|
assert info is not None
|
||||||
|
assert info.package == "com.github.damontecres.wholphin"
|
||||||
|
assert info.position == 17237
|
||||||
|
assert client.all_sessions[0].playing is True
|
||||||
|
|
||||||
|
async def test_failed_shell_returns_none_without_reparsing(self):
|
||||||
|
client = _client_response(CMD_MEDIA_DEFAULT, Exception("dead"))
|
||||||
|
assert await client.poll_media_sessions() is None
|
||||||
|
assert client.last_info is None
|
||||||
Reference in new issue
Block a user