fix(media-player): ON means no power or playback fact reported (t58 UNIT 0)

The t59 F2 ruling, implemented. The ladder said ON was for "no source reported
anything at all" but tested for something looser — no transport data — so a
source that ANSWERED "not playing" still fell through to ON. A source that
answers is not a source that is silent, and `on` is the "we cannot know" state.

The ladder now tracks an explicit `reported` flag, set by every source that gave
us a power or playback fact, and the last tier is `IDLE if reported else ON`.
Each case the ruling names now lands on IDLE:

  * a configured Jellyfin entity in ANY state — idle, off, standby, unavailable,
    or even absent from the state machine. It reports "not playing" without an
    early return, so a silent Jellyfin still cannot mask a live media session
    below it (both directions pinned);
  * a live media session that carries no playback flag — the app publishes a
    session but is not playing, paused or buffering;
  * a bare foreground app: either transport's answer counts (the protocol's
    current_app push or the ADB mResumedActivity parse);
  * a configured power entity, whatever it currently says;
  * protocol is_on / ADB awake, plugged or not.

`on` survives only for an entry where none of those exists. The t55 rules are
untouched: an off power entity still outranks a playing session, and no source
may mask playing/paused/buffering.

The module docstring's tier 6, the `state` docstring and the README's ladder
(moving "no data" to the precise wording) all say the same thing now, and the
README's one-line state table was aligned with it.

RED PROOF (the new tests against the parent tree): five of the reporting cases
read `on` where they must read `idle` — `assert 'on' == 'idle'` — plus the
"ON is unreachable with any source configured" shape test.

SIMULATED vs EXECUTED: no real Shield was available, so these are the documented
source shapes, not captures; the state=6 → buffering mapping stays marked
inferred. No browser exists either, so the README pins are textual and the
production behaviour is exercised against real HA's state machine in
tests/test_ha_api_drift.py.
This commit is contained in:
James Mackay committed 2026-10-12 08:04:06 +01:00
1 parent ff8dd1c454
commit ef42682250
4 files changed
+228 -30

No files matched your search

+71 -23
View File
@@ -31,13 +31,29 @@ source that has something to say, and the earlier steps always win:
powered on (protocol is_on True / ADB awake / the plug is on)
and NOTHING is playing. "Powered on, app open, nothing
playing" is IDLE for exactly this reason.
6. ON — NOTHING reported anything at all: no transport connected, no
power entity, no Jellyfin, no media session, no intent. ON is
6. ON — NOTHING reported a POWER OR PLAYBACK FACT — not "no data" (t58
UNIT 0, the t59 F2 ruling, which this wording follows). ON is
the "we cannot know" state and the only situation it may
describe. It must NEVER appear while any source reported a
power or media state, and it must never mask a
playing/paused/buffering source — the rule that fixes the live
"On overrides my playing states" report.
describe, so every source that ANSWERS — even to say "not
playing" — takes the ladder to IDLE at worst, never here:
* a configured Jellyfin entity in ANY state, including idle /
off / standby / unavailable (and it reports "not playing"
without ever masking a live media session, because the
playing/paused tiers above still win);
* a live media session that carries no playback flag, i.e. the
app publishes a session but is not playing, paused or
buffering;
* a bare foreground app — the protocol's current-app push or
the ADB mResumedActivity parse answered;
* a configured power entity, whatever it currently says;
* protocol is_on / ADB awake, plugged or not.
ON survives for an entry where NONE of those exists: no
transport data, no power entity, no Jellyfin entity, no session,
no foreground app. It must never mask a playing/paused/buffering
source — the rule that fixes the live "On overrides my playing
states" report.
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
1. User presses pause → intent recorded → UI shows paused immediately
@@ -676,17 +692,28 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
Exactly ``{playing, paused, buffering, idle, off}`` are reported for a
device we have data about; ``ON`` is reserved for the case where NO
source reported anything at all (no transport connected, no power and
no media data) and must never mask a playing/paused/buffering source.
A powered-on Shield with an app open but nothing playing reads IDLE,
not ON (t55 — the live defect users read as "On overrides my playing
states").
source reported a POWER OR PLAYBACK FACT — not merely "no transport is
connected" (t58 UNIT 0, the t59 F2 ruling). A source that exists and
answers "not playing" (Jellyfin idle/off/standby/unavailable, a live
media session with no playback flag, a bare foreground app) HAS reported,
so the ladder stops at IDLE, never ON. ON must also never mask a
playing/paused/buffering source. A powered-on Shield with an app open but
nothing playing reads IDLE, not ON (t55 — the live defect users read as
"On overrides my playing states").
"""
# A source that reported a power or playback fact, even a negative one.
# ON is the "we cannot know" state, so it survives only when nothing at
# all answered — this flag IS the distinction the ruling turns on.
reported = False
# 1. Explicit OFF. The plug being off is definitive; the protocol's
# is_on push is the device reporting its own power state. Either
# one OUTRANKS every media source — a stale Jellyfin session must
# never resurrect playback on a device that is off.
if self._power_entity:
# a CONFIGURED source counts as reporting whatever it says now,
# which is why an unavailable plug yields IDLE rather than ON
reported = True
power_state = self._read(self._power_entity)
if power_state and power_state.state == "off":
return MediaPlayerState.OFF
@@ -698,7 +725,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if self._intent is not None:
return MediaPlayerState(self._intent.target_state)
# 3. Jellyfin (slow, but authoritative for its own sessions).
# 3. Jellyfin (slow, but authoritative for its own sessions). A
# configured entity reports in EVERY state it can be in: playing and
# paused are truth, and idle / off / standby / unavailable / missing
# all mean "its session is not playing" — reported, just not
# playable, so the ladder continues WITHOUT an early return (a silent
# Jellyfin must never mask a live media session) and IDLE is where it
# ends (t58 UNIT 0).
if self._jellyfin_entity:
reported = True
jf = self._read(self._jellyfin_entity)
if jf:
if jf.state == STATE_PLAYING:
@@ -706,11 +741,16 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if jf.state == STATE_PAUSED:
return MediaPlayerState.PAUSED
# 4. Live media session (non-Jellyfin apps), buffering included.
# 4. Live media session (non-Jellyfin apps), buffering included. The
# session EXISTING is a report even when it carries no playback flag
# (an idle or paused app still publishes one): that is IDLE, and it is
# the t58 UNIT 0 case that used to fall through to ON.
session = manager.best_session(self.app_id) if manager is not None else None
session_state = self._session_state(session)
if session_state is not None:
return session_state
if session is not None:
reported = True
session_state = self._session_state(session)
if session_state is not None:
return session_state
# 5. ADB standby: powered but asleep — still data (the device
# answered), so IDLE, never ON. media_player.STANDBY is deprecated
@@ -721,14 +761,22 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# 6. The device is on (protocol is_on True, ADB awake, or the power
# plug is on — every one of those is a source that reported
# something). An open app or an awake device reads IDLE, not ON.
power = self._is_power_on()
if power is not None or self._power_entity:
return MediaPlayerState.IDLE
# A bare foreground app is a report in its own right: the protocol's
# current-app push or the ADB mResumedActivity parse answered.
if self._is_power_on() is not None:
reported = True
if manager is not None and manager.awake is not None:
reported = True
# EITHER transport's foreground answer counts: the protocol's
# current_app push and the ADB mResumedActivity parse are both
# "something reported", even when neither is playing anything.
if manager is not None and manager.foreground_app is not None:
reported = True
# 7. Nothing anywhere reported anything: no transport connected, no
# power entity, no media source. ON is the "we cannot know" state,
# and the only situation it is allowed to describe.
return MediaPlayerState.ON
# 7. Nothing anywhere reported a power or playback fact. ON is the "we
# cannot know" state, and the only situation it is allowed to
# describe.
return MediaPlayerState.IDLE if reported else MediaPlayerState.ON
@property
def supported_features(self) -> MediaPlayerEntityFeature: