docs: add MVP plan and Shield firmware research findings

This commit is contained in:
Levi committed 2026-10-08 07:18:27 +01:00
1 parent 41d3b94107
commit afc5f0a1d0
2 files changed
+908

No files matched your search

+396
View File
@@ -0,0 +1,396 @@
# Shield firmware research results (§8 of MVP_PLAN.md, R1–R9)
Captured from the user's Shield. These outputs are the ground truth for parser
fixtures and resolved the R2 verdict. Plan deltas applied to `MVP_PLAN.md` are
listed at the end.
## Environment
| Property | Value |
|---|---|
| Device | NVIDIA Shield (board `mdarcy`) |
| Android | 11 (`ro.build.version.release=11`) |
| Fingerprint | `NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7825230_4231.0265:user/release-keys` |
| SDK | 30 (Android 11; the `getprop ro.build.version.sdk` call failed on shell truncation — `etprop: inaccessible or not found` — but Android 11 ⇒ SDK 30) |
Note: the home launcher is **not** the stock Shield launcher — it is
`com.spocky.projengmenu` (Project Eternity menu). Foreground detection must not
assume `com.google.android.tv.launcher`.
---
## R1 — TiviMate package name: `ar.tvplayer.tv`
```
$ adb shell pm list packages | grep -iE 'tivi|^package:iv'
package:ar.tvplayer.tv
```
**Verdict:** TiviMate's package is `ar.tvplayer.tv` — **not** `iv.android.tv` as
the plan assumed. All match keys, fixtures, and constants use `ar.tvplayer.tv`.
---
## R2 — Does TiviMate publish a media session? **NO** (verdict: branch 3)
Full `dumpsys media_session` with TiviMate in the foreground playing a live
channel (captured twice, before/after zapping):
```
MEDIA SESSION SERVICE (dumpsys media_session)
9 sessions listeners.
Global priority session is null
User Records:
Record for full_user=0
...
Sessions Stack - have 1 sessions:
Netflix media session com.netflix.ninja/Netflix media session (userId=0)
ownerPid=5833, ownerUid=10080, userId=0
package=com.netflix.ninja
launchIntent=null
mediaButtonReceiver=null
active=false
flags=3
rating type=0
controllers: 0
state=null
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_UNKNOWN flags=0x800 tags= bundle=null
volumeType=1, controlType=2, max=0, current=0
metadata: null
queueTitle=null, size=0
Audio playback (lastly played comes first)
uid=10117 packages=ar.tvplayer.tv
uid=10006 packages=com.google.android.apps.mediashell
```
**Findings:**
1. **No TiviMate session block at all.** TiviMate registers no MediaSession —
it cannot be reached via the session stack. It is a pure Phase-3 fallback app.
2. The only session present is **Netflix's, stale/inactive**: `active=false`,
`state=null`, `metadata=null`. The current parser requires `active=true` +
metadata, so it correctly ignores it — but this exact dump must become a
fixture pinning that behavior ("inactive no-state sessions are ignored").
3. The before/after-zap captures are **byte-identical** — zapping channels
changes nothing in `dumpsys media_session`.
### NEW discovery — the "Audio playback" section
The bottom of `dumpsys media_session` lists **apps that recently played audio**,
even with no MediaSession:
```
Audio playback (lastly played comes first)
uid=10117 packages=ar.tvplayer.tv
uid=10006 packages=com.google.android.apps.mediashell
```
- TiviMate shows up here while playing live TV. This is a **zero-cost
supplementary signal**: it comes from the *same* `dumpsys media_session` call
the integration already makes — no extra round trip.
- Use: confirm "this foreground app actually has/played media" (foreground =
TiviMate + recent audio playback ⇒ live TV likely playing). Distinguishes
"sitting in TiviMate's settings" from "watching a channel".
- Note `com.google.android.apps.mediashell` (Chromecast receiver process) also
appears — a parse-time ignore-list entry.
---
## R4 — Foreground detection format (Android 11): `mResumedActivity` is the pattern
```
$ adb shell dumpsys activity activities | grep -iE 'resumed'
mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity
t8152}
state=RESUMED stopped=false delayedResume=false finishing=false
Resumed activities in task display areas (from top to bottom):
Resumed: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
ResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
```
```
$ adb shell dumpsys window | grep -E 'mCurrentFocus|mFocusedApp'
mCurrentFocus=null
mFocusedApp=null
mCurrentFocus=Window{980f3f1 u0 ar.tvplayer.tv/ar.tvplayer.tv.ui.MainActivity}
mFocusedApp=ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
```
**Findings:**
1. **No `topResumedActivity` on this firmware** (that pattern is newer Android).
Primary pattern = `mResumedActivity: ActivityRecord{… u0 <pkg>/<activity> …}`.
Keep `topResumedActivity` parsing for future-proofing, checked *after*
`mResumedActivity`.
2. `dumpsys window` contains **multiple** `mCurrentFocus` entries and the first
can be literally `mCurrentFocus=null` — a naive first-match parser breaks.
`mResumedActivity` is the reliable single source; window/focus parsing is a
secondary cross-check at most.
3. Package extraction: from the `u0 <pkg>/<activity>` fragment of the
ActivityRecord line (handles multi-line wraps in the raw dump — match on the
joined block, not per-line).
4. Launcher foreground (for the "off/idle in launcher" distinction): with the
user's custom launcher focused, `mResumedActivity` shows
`com.spocky.projengmenu/.ui.home.MainActivity`.
---
## R5 — Wake state: exact value match confirmed
```
Awake: mWakefulness=Awake
mWakefulnessChanging=false
Asleep: mWakefulness=Asleep
mWakefulnessChanging=false
```
**Verdict:** the current `"Awake" in result` substring heuristic happens to
work on this firmware, but switch to an exact value parse of the
`mWakefulness=` assignment (robust against future values like
`mWakefulness=Dozing`).
---
## R6 — Recent-tasks: **not run** (optional; low risk)
Still outstanding if `media_dag_running_apps` needs verification on this
firmware.
---
## R7 — Channel digit keyevents: **skipped by decision**
The user already uses `media_player.play_media` with
`media_content_type: channel` through the androidtv_remote integration and
reports it "works perfectly fine" — so we **replicate core's exact behavior**
rather than live-testing keyevent variants:
- digits only (no confirm/ENTER key), inter-digit delay **0.1 s** (core default),
- cancel any prior in-flight channel task before sending,
- keep ENTER as an *optional* boolean (default off) in case a future app needs it.
R7 stays in the doc as an optional verification step if protocol-path digits
ever misbehave.
---
## R8 — Logcat event reliability: `ActivityTaskManager: START` lines confirmed
```
10-08 06:54:33.748 3693 5205 I ActivityTaskManager: START u0
{act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER]
flg=0x10200000 cmp=ar.tvplayer.tv/.ui.MainActivity} from uid 2000
10-08 06:54:50.439 3693 5212 I ActivityTaskManager: START u0
{act=android.intent.action.MAIN
cat=[android.intent.category.LEANBACK_LAUNCHER] flg=0x10220000
pkg=com.spocky.projengmenu cmp=com.spocky.projengmenu/.ui.home.MainActivity
(has extras)} from uid 10059
```
**Findings:**
1. `ActivityTaskManager: START u0 {… cmp=<pkg>/<activity>}` lines are emitted on
every app open — including the **home button** (which sends a
`LEANBACK_LAUNCHER` START for the launcher). App-switch detection via logcat
delta snapshots is viable in both directions.
2. `WindowManager` lines were absent from the capture — rely on
`ActivityTaskManager` first.
3. The user has **`be.digitalia.mediasession2mqtt`** installed
([MediaSession2MQTT](https://github.com/cbeyls/MediaSession2MQTT)) — a
notification-listener service that publishes media sessions over MQTT. It is
part of their current glue (see appendix) and is a candidate to retire once
our session + fallback path covers its sensors.
---
## R9 — PlaybackState layout + a known-good Wholphin session
```
state=PlaybackState {state=3, position=17237, buffered position=70070,
speed=1.0, updated=1408392, actions=7339995, custom actions=[], active item
id=0, error=null}
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_MOVIE flags=0x800 tags= bundle=null
volumeType=1, controlType=2, max=0, current=0
metadata: size=11, description=Twirlywoos, null, null
queueTitle=null, size=1
```
**Findings:**
1. `PlaybackState` carries everything we planned to parse: `state=3` (playing),
`position=17237` (ms), `speed=1.0`, `updated=1408392` (**uptime ms**, needs
conversion — position math must account for the uptime-vs-wallclock gap;
mirror HA's convention by computing `position + (now - updated_time)` with
uptime anchoring).
2. No duration in PlaybackState — duration lives in metadata keys
(`android.media.metadata.DURATION` inside the size=11 metadata dict, beyond
the `description=` field the parser currently reads). Parsing the full
metadata key list is a Phase-1 stretch goal; `description=` gives
title/artist/album positionally as before.
3. The session above is **Wholphin** (`com.github.damontecres.wholphin`) — a
Jellyfin client that publishes a proper session with usable metadata. Add it
to `JELLYFIN_APP_IDS` (that constant is currently defined but **unused** —
wire it up) so Jellyfin-family clients route to the rich-metadata path.
---
## play_media channel — current working service call (replicate exactly)
```yaml
action: media_player.play_media
data:
media:
media_content_id: '{{ channel_number|int }}'
media_content_type: channel
metadata: {}
target:
entity_id:
- media_player.shield_android_tv
```
Note the current HA service syntax uses the `media:` block; the entity's
`async_play_media(media_type, media_id)` receives `"channel"` / `"<n>"` the same
as before.
---
## Appendix — the user's current glue this integration replaces
Recorded as requirements context. This is what works today and what MVP should
make unnecessary.
### Template sensor (app name from the androidtv media_player)
```yaml
template:
- triggers:
- trigger: homeassistant
event: start
- trigger: state
entity_id:
- media_player.android_tv_10_50_204_4
attribute: app_name
sensor:
- name: Nvidia Shield App Name
unique_id: media_player_nvidia_shield_app_name
icon: "mdi:application"
state: >
{% set adb = "media_player.android_tv_10_50_204_4" %}
{% set adb_state = states(adb) %}
{% if adb_state not in ['unknown', 'unavailable', 'off'] %}
{{ state_attr(adb,'app_name') }}
{% elif adb_state in ['off'] %}
{{ "off" }}
{% else %}
{{ "unknown" }}
{% endif %}
```
### Universal media player (state merging + command routing)
```yaml
media_player:
platform: universal
name: Nvidia Shield
device_class: tv
unique_id: media_player_nvidia_shield
children:
- media_player.android_tv_10_50_204_4
browse_media_entity: media_player.android_tv_10_50_204_4
state_template: >-
{% set shield_state = states('media_player.android_tv_10_50_204_4') %}
{% set known_app = states('sensor.mediasession2mqtt_2044_application_id') in ["com.google.android.youtube.tv", "com.github.damontecres.wholphin"] %}
{% set wholphin = is_state('sensor.nvidia_shield_app_name', 'Wholphin') %}
{% if(shield_state == "unknown" or shield_state == "unavailable" or shield_state == "off") %}
off
{% elif wholphin %}
{% set playing_state = is_state('binary_sensor.jellyfin_nvidia_shield_playing', 'on') %}
{% set paused_state = is_state('binary_sensor.jellyfin_nvidia_shield_paused', 'on') %}
{% if paused_state %}
paused
{% elif playing_state %}
playing
{% else %}
idle
{% endif %}
{% elif known_app %}
{{ states("sensor.mediasession2mqtt_2044_playback_state") }}
{% else %}
{{ shield_state }}
{% endif %}
commands:
turn_on:
action: script.turn_on_nvidia_shield
turn_off:
action: script.turn_off_nvidia_shield
volume_mute:
action: switch.toggle
target: {entity_id: switch.tv_cabinet_livingroom_speaker_system}
volume_up:
action: script.volume_up_nvidia_shield
volume_down:
action: script.volume_down_nvidia_shield
volume_set:
action: script.volume_set_nvidia_shield
data: {level: "{{ volume_level }}"}
select_source:
action: script.set_specific_app_nvidia_shield
data: {app_name: "{{ source }}"}
attributes:
is_volume_muted: switch.inverse_tv_cabinet_speakers
volume_level: media_player.android_tv_10_50_204_4|volume_level
source: remote.shield_android_tv|current_activity
source_list: remote.shield_android_tv|activity_list
```
### What the MVP replaces vs. keeps
| Current glue | MVP after Phase 2/3 |
|---|---|
| `sensor.nvidia_shield_app_name` template sensor | Current App sensor reads StateStore (protocol `current_app` push) |
| `mediasession2mqtt` sensors (`*_application_id`, `*_playback_state`) | ADB media sessions + fallback state (retire the MQTT bridge) |
| Universal player `state_template` app special-casing | Native state ladder |
| `browse_media_entity` from androidtv_remote | Phase 4 BrowseMedia app directory |
| `volume_*` scripts (speaker switch + receiver interplay) | **Stays as user automations** — but `volume_set` gains a real protocol path (Phase 2 VOLUME_SET); the receiver/speaker switch logic remains the user's or moves to media zone |
| `remote.shield_android_tv` for source/activity | Our own remote entity (Phase 2) |
---
## Fixtures to save into `tests/fixtures/`
| File | Content |
|---|---|
| `dumpsys_media_session_tivimate_playing.txt` | R2 capture 1 (Netflix stale session + audio playback listing) |
| `dumpsys_media_session_tivimate_zapped.txt` | R2 capture 2 (identical — pins zap-invariance) |
| `dumpsys_media_session_wholphin_playing.txt` | R9 capture (playing session with position/metadata) |
| `dumpsys_activity_activities_tivimate.txt` | R4 capture 1 |
| `dumpsys_activity_activities_launcher.txt` | R4 capture 2 (custom launcher) |
| `dumpsys_window_focused.txt` | R4 window capture |
| `dumpsys_power_wakefulness_{awake,asleep}.txt` | R5 captures |
| `logcat_activitytaskman_start.txt` | R8 capture |
---
## Plan deltas applied to `MVP_PLAN.md`
1. TiviMate package corrected everywhere: `ar.tvplayer.tv`.
2. R2 verdict resolved: no session → TiviMate is the headline Phase-3 fallback
app; README gets it as a known case.
3. **New fallback signal adopted:** "Audio playback (lastly played comes first)"
section of `dumpsys media_session` — zero extra round trip; parsed alongside
sessions; `com.google.android.apps.mediashell` in the ignore list.
4. Foreground parser: `mResumedActivity` primary (Android 11),
`topResumedActivity` newer-fallback; `mCurrentFocus=null` trap documented;
launcher special-casing not stock.
5. Wake parse: exact `mWakefulness=` value match.
6. `play_media(channel)`: replicate core behavior exactly (digits, 0.1 s, no
ENTER); R7 marked skipped-by-decision.
7. Wholphin (`com.github.damontecres.wholphin`) added to `JELLYFIN_APP_IDS`;
wiring that constant becomes a Phase-0/1 item.
8. Position math: `updated` is uptime-ms — needs uptime anchoring in Phase 1.
9. Migration note: retire mediasession2mqtt sensors + universal-player YAML once
Phase 3 lands (documented in README).
10. Netflix stale-session fixture pins "inactive no-state sessions ignored".