feat(media_session): map Android PlaybackState 6 to buffering (t55)

The media session parser only understood states 2 (paused) and 3
(playing), so a session refilling its buffer (Android state 6, which the
Shield's own ExoPlayer/MediaSession sessions report exactly like stock
Android) read as "idle" or vanished into the foreground tier.

MediaSessionInfo gains an independent `buffering` flag (playing/paused/
buffering are now three flags off one integer), the session sort puts
buffering right behind playing — it is mid-playback, so it outranks a
paused session when the ladder picks one — and the running-apps
enrichment names a buffering live/last-known session "buffering" instead
of falling into "idle".

The MediaPlayerState stub gains BUFFERING and STANDBY so an in-suite
reference to a member real HA does not have fails here (real HA 2026.10
exposes off/on/idle/playing/paused/standby/buffering).
This commit is contained in:
James Mackay committed 2026-10-12 06:52:58 +01:00
1 parent 49bc23ae34
commit 07682ff082
4 files changed
+121 -8

No files matched your search

@@ -17,6 +17,12 @@ class MediaSessionInfo:
**device uptime-ms** timestamp (verified on Shield Android 11, R9), not a **device uptime-ms** timestamp (verified on Shield Android 11, R9), not a
wallclock value. Use :meth:`position_at_uptime` with the device's current wallclock value. Use :meth:`position_at_uptime` with the device's current
uptime to extrapolate the live position. uptime to extrapolate the live position.
``playing``/``paused``/``buffering`` are independent flags mapped from the
Android PlaybackState integer: 2 = paused, 3 = playing, 6 = buffering
(t55). The Shield's own ExoPlayer/MediaSession sessions report state 6
while buffering just as stock Android does, so no vendor-specific state
integer is treated as buffering.
""" """
title: str | None = None title: str | None = None
@@ -25,6 +31,7 @@ class MediaSessionInfo:
package: str | None = None package: str | None = None
playing: bool = False playing: bool = False
paused: bool = False paused: bool = False
buffering: bool = False
position: float | None = None position: float | None = None
speed: float | None = None speed: float | None = None
updated: int | None = None updated: int | None = None
@@ -81,7 +88,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
"""Parse `dumpsys media_session` output for ALL active media sessions. """Parse `dumpsys media_session` output for ALL active media sessions.
Returns a list of every active session that has metadata or a known Returns a list of every active session that has metadata or a known
playback state, sorted: playing first, then paused, then others. playback state, sorted: playing first, then buffering, then paused, then
others (t55).
""" """
if not dump: if not dump:
return [] return []
@@ -89,7 +97,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
sessions: list[MediaSessionInfo] = [] sessions: list[MediaSessionInfo] = []
current_pkg: str | None = None current_pkg: str | None = None
in_active = False in_active = False
current_state = 0 # 0=unknown, 2=paused, 3=playing # Android PlaybackState: 0=unknown, 2=paused, 3=playing, 6=buffering
current_state = 0
current_position: float | None = None current_position: float | None = None
current_speed: float | None = None current_speed: float | None = None
current_updated: int | None = None current_updated: int | None = None
@@ -138,6 +147,7 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
info = MediaSessionInfo() info = MediaSessionInfo()
info.package = current_pkg info.package = current_pkg
info.playing = current_state == 3 info.playing = current_state == 3
info.buffering = current_state == 6
info.paused = current_state == 2 info.paused = current_state == 2
info.position = current_position info.position = current_position
info.speed = current_speed info.speed = current_speed
@@ -158,13 +168,16 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
current_meta.package = current_pkg current_meta.package = current_pkg
sessions.append(current_meta) sessions.append(current_meta)
# Sort: playing first, then paused, then others # Sort: playing first, then buffering (mid-playback), then paused, then
# others — a buffering session outranks a paused one (t55).
def sort_key(s: MediaSessionInfo) -> int: def sort_key(s: MediaSessionInfo) -> int:
if s.playing: if s.playing:
return 0 return 0
if s.paused: if s.buffering:
return 1 return 1
if s.paused:
return 2 return 2
return 3
sessions.sort(key=sort_key) sessions.sort(key=sort_key)
return sessions return sessions
@@ -206,8 +219,9 @@ def parse_media_session(
Returns the best match from all active sessions: Returns the best match from all active sessions:
1. The session whose package matches the foreground app (if provided) 1. The session whose package matches the foreground app (if provided)
2. The session that is currently playing (state=3) 2. The session that is currently playing (state=3)
3. The session that is paused (state=2) 3. The session that is buffering (state=6)
4. Any active session with metadata 4. The session that is paused (state=2)
5. Any active session with metadata
""" """
sessions = parse_all_media_sessions(dump) sessions = parse_all_media_sessions(dump)
if not sessions: if not sessions:
@@ -218,6 +232,5 @@ def parse_media_session(
for s in sessions: for s in sessions:
if s.package == foreground_package: if s.package == foreground_package:
return s return s
# Already sorted: playing > buffering > paused > other
# Already sorted: playing > paused > other
return sessions[0] return sessions[0]
@@ -51,8 +51,11 @@ def running_apps_from_tasks(
if session: if session:
if session.playing: if session.playing:
app["state"] = "playing" app["state"] = "playing"
elif session.buffering:
app["state"] = "buffering"
elif session.paused: elif session.paused:
app["state"] = "paused" app["state"] = "paused"
app["state"] = "paused"
else: else:
app["state"] = "idle" app["state"] = "idle"
if session.title: if session.title:
@@ -66,6 +69,8 @@ def running_apps_from_tasks(
if cached is not None: if cached is not None:
if cached.playing: if cached.playing:
app["last_known_state"] = "playing" app["last_known_state"] = "playing"
elif cached.buffering:
app["last_known_state"] = "buffering"
elif cached.paused: elif cached.paused:
app["last_known_state"] = "paused" app["last_known_state"] = "paused"
if cached.title: if cached.title:
+2
View File
@@ -79,6 +79,8 @@ class MediaPlayerState(enum.StrEnum):
IDLE = "idle" IDLE = "idle"
PLAYING = "playing" PLAYING = "playing"
PAUSED = "paused" PAUSED = "paused"
STANDBY = "standby"
BUFFERING = "buffering"
class MediaPlayerEntityFeature(enum.IntFlag): class MediaPlayerEntityFeature(enum.IntFlag):
+93
View File
@@ -125,6 +125,76 @@ class TestPlaybackStatePosition:
assert sessions[0].position is None assert sessions[0].position is None
class TestPlaybackStateBufferingT55:
"""t55: Android PlaybackState 6 is BUFFERING, mapped in the parser.
The Shield's own sessions report the same integer stock Android does (6),
so no vendor-specific variant is treated as buffering — a device variant
would be a template field on the same PlaybackState line, never a
different integer.
"""
def _buffering_dump(self, state=6, speed="0.0"):
return (
"Sessions Stack - have 1 sessions:\n"
" Some app session com.netflix.ninja/Session (userId=0)\n"
" package=com.netflix.ninja\n"
" active=true\n"
f" state=PlaybackState {{state={state}, position=0, "
f"buffered position=2000, speed={speed}, updated=900, actions=1, "
"custom actions=[], active item id=0, error=null}\n"
" metadata: size=2, description=Movie Title, Some Artist\n"
)
def test_state_6_is_buffering(self):
sessions = parse_all_media_sessions(self._buffering_dump())
assert len(sessions) == 1
session = sessions[0]
assert session.buffering is True
assert session.playing is False
assert session.paused is False
assert session.title == "Movie Title"
def test_state_3_and_2_stay_playing_and_paused(self):
for state, attr in ((3, "playing"), (2, "paused")):
sessions = parse_all_media_sessions(self._buffering_dump(state=state))
session = sessions[0]
assert session.buffering is False
assert getattr(session, attr) is True
def test_state_6_wins_when_the_dump_says_speed_zero_too(self):
"""The buffering signal is the integer, not the speed field."""
sessions = parse_all_media_sessions(self._buffering_dump(state=6))
assert sessions[0].buffering is True
def test_buffering_outranks_a_paused_session_in_the_pick_order(self):
sessions = parse_all_media_sessions(
self._buffering_dump()
+ " Other app session com.example.player/Session (userId=0)\n"
" package=com.example.player\n"
" active=true\n"
" state=PlaybackState {state=2, position=1, speed=0.0, "
"updated=1, actions=1, custom actions=[], active item id=0, "
"error=null}\n"
" metadata: size=1, description=Paused Tune\n"
)
assert [s.package for s in sessions] == [
"com.netflix.ninja",
"com.example.player",
]
# and parse_media_session (no foreground preference) picks buffering
dump = self._buffering_dump() + (
" Other app session com.example.player/Session (userId=0)\n"
" package=com.example.player\n"
" active=true\n"
" state=PlaybackState {state=2, position=1, speed=0.0, "
"updated=1, actions=1, custom actions=[], active item id=0, "
"error=null}\n"
" metadata: size=1, description=Paused Tune\n"
)
assert parse_media_session(dump).package == "com.netflix.ninja"
class TestAudioPlaybackSection: class TestAudioPlaybackSection:
"""The "Audio playback (lastly played comes first)" section lists apps """The "Audio playback (lastly played comes first)" section lists apps
that recently played audio even with no MediaSession (R2).""" that recently played audio even with no MediaSession (R2)."""
@@ -160,6 +230,29 @@ class TestAudioPlaybackSection:
assert parse_audio_playback("") == [] assert parse_audio_playback("") == []
def test_running_apps_buffering_state_t55():
"""t55: a buffering session (and a buffering last-known) is named buffering."""
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
tasks = [
{"package": "com.netflix.ninja"},
{"package": "ar.tvplayer.tv"},
]
sessions = [MediaSessionInfo(package="com.netflix.ninja", buffering=True)]
last_known = {
"ar.tvplayer.tv": MediaSessionInfo(package="ar.tvplayer.tv", buffering=True)
}
apps = running_apps_from_tasks(
tasks, sessions, foreground_package=None, last_known=last_known
)
netflix = next(a for a in apps if a["package"] == "com.netflix.ninja")
tivi = next(a for a in apps if a["package"] == "ar.tvplayer.tv")
assert netflix["state"] == "buffering"
assert tivi["last_known_state"] == "buffering"
def test_running_apps_last_known_enrichment(): def test_running_apps_last_known_enrichment():
"""P3-3b: apps without a live session carry their TTL-capped last-known state.""" """P3-3b: apps without a live session carry their TTL-capped last-known state."""
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks