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
wallclock value. Use :meth:`position_at_uptime` with the device's current
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
@@ -25,6 +31,7 @@ class MediaSessionInfo:
package: str | None = None
playing: bool = False
paused: bool = False
buffering: bool = False
position: float | None = None
speed: float | 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.
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:
return []
@@ -89,7 +97,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
sessions: list[MediaSessionInfo] = []
current_pkg: str | None = None
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_speed: float | None = None
current_updated: int | None = None
@@ -138,6 +147,7 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
info = MediaSessionInfo()
info.package = current_pkg
info.playing = current_state == 3
info.buffering = current_state == 6
info.paused = current_state == 2
info.position = current_position
info.speed = current_speed
@@ -158,13 +168,16 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
current_meta.package = current_pkg
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:
if s.playing:
return 0
if s.paused:
if s.buffering:
return 1
if s.paused:
return 2
return 3
sessions.sort(key=sort_key)
return sessions
@@ -206,8 +219,9 @@ def parse_media_session(
Returns the best match from all active sessions:
1. The session whose package matches the foreground app (if provided)
2. The session that is currently playing (state=3)
3. The session that is paused (state=2)
4. Any active session with metadata
3. The session that is buffering (state=6)
4. The session that is paused (state=2)
5. Any active session with metadata
"""
sessions = parse_all_media_sessions(dump)
if not sessions:
@@ -218,6 +232,5 @@ def parse_media_session(
for s in sessions:
if s.package == foreground_package:
return s
# Already sorted: playing > paused > other
# Already sorted: playing > buffering > paused > other
return sessions[0]
@@ -51,8 +51,11 @@ def running_apps_from_tasks(
if session:
if session.playing:
app["state"] = "playing"
elif session.buffering:
app["state"] = "buffering"
elif session.paused:
app["state"] = "paused"
app["state"] = "paused"
else:
app["state"] = "idle"
if session.title:
@@ -66,6 +69,8 @@ def running_apps_from_tasks(
if cached is not None:
if cached.playing:
app["last_known_state"] = "playing"
elif cached.buffering:
app["last_known_state"] = "buffering"
elif cached.paused:
app["last_known_state"] = "paused"
if cached.title:
+2
View File
@@ -79,6 +79,8 @@ class MediaPlayerState(enum.StrEnum):
IDLE = "idle"
PLAYING = "playing"
PAUSED = "paused"
STANDBY = "standby"
BUFFERING = "buffering"
class MediaPlayerEntityFeature(enum.IntFlag):
+93
View File
@@ -125,6 +125,76 @@ class TestPlaybackStatePosition:
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:
"""The "Audio playback (lastly played comes first)" section lists apps
that recently played audio even with no MediaSession (R2)."""
@@ -160,6 +230,29 @@ class TestAudioPlaybackSection:
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():
"""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