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:
1 parent
49bc23ae34
commit
07682ff082
4 files changed
+122
-9
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
|
||||||
return 2
|
if s.paused:
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user