docs: add MVP plan and Shield firmware research findings
This commit is contained in:
1 parent
41d3b94107
commit
afc5f0a1d0
2 files changed
+908
No files matched your search
@@ -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".
|
||||
Reference in new issue
Block a user