feat(adb_client): exact-value mWakefulness= parse (R5), health_check(), optional media-session truncation (B4); wakefulness fixtures + tests

This commit is contained in:
James Mackay committed 2026-10-08 08:41:21 +01:00
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 logging
import re
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
@@ -31,6 +32,33 @@ 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:
"""Base ADB client with connection management and common commands."""
@@ -143,27 +171,50 @@ class AdbClientBase:
return result is not 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")
if result:
return "Awake" in result
return None
return parse_wakefulness(result)
# -- Media session polling --
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:
"""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}"
)
"""Query media sessions. Stores all sessions and returns the best match.
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:
return None
self._all_sessions = parse_all_media_sessions(dump)
self._last_info = parse_media_session(dump, foreground_package)
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) --
async def poll_running_apps(self) -> list[dict]:
@@ -180,7 +231,6 @@ class AdbClientBase:
if not result:
return []
import re
apps = []
seen = set()