The reported Shield names no app: `pm dump com.netflix.ninja` is ~2241 lines of
dumpsys and grep for `application-label` over the whole capture finds nothing.
The probe shipped `| head -300`, which cut the dump BEFORE the package section,
so "no label" was a truncation artefact; the sweep then named every app after
its own package id, and the apps editor pre-filled the `pkg=pkg` row the user
read as a bug.
* the probe greps ON THE DEVICE for the three shapes Android prints
(`application-label`, `nonLocalizedLabel`, `labelRes`); a quoted value is a
name, `null` and a resource id are not. The dump is never truncated.
* after APP_LABEL_PROBE_LIMIT consecutive "ran, no label" answers the device
is taken at its word: the sweep stops spending a shell per package
(APP_LABEL_TTL expiry re-checks, and a package whose probe FAILED still owes
its retry — t57 UNIT 0 survives, driven by the app on screen).
* ONE precedence chain in `ha_adb_common/app_label.resolve_package_name`, used
by the media player, the Current App sensor, the discovery table, the source
list and the apps editor: user map > device label > curated map > package id
(the id shown ONCE, `name` empty when nothing names the app).
* the curated table covers the user's 21 apps (community-maintained, not
device truth) and the Configure row says when the device named nothing.
* the real device shape is a test fixture (trimmed, and marked as such in the
file: the raw capture is not in the repo) pinning that no label is
extracted, that the probe count is bounded, and that `Netflix` surfaces.
Also drops the dead `ATTR_MEDIA_DAG_NOW_PLAYING` re-exports left by the t65
flattening and updates the RESEARCH_SHIELD now-playing note to the flat names.
No real Shield was available: the fetch/burst behaviour is exercised against the
documented Android output and the trimmed reproduction of the reported capture.
424 lines
17 KiB
Markdown
424 lines
17 KiB
Markdown
# 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".
|
||
|
||
---
|
||
|
||
## Appendix — mediasession2mqtt retirement + universal-player glue (Phase 3)
|
||
|
||
The fork retires the surrounding glue once the state ladder (README, Phase 3)
|
||
covers what the external pieces provided:
|
||
|
||
| External piece | What it provided | Now native (file) |
|
||
|---|---|---|
|
||
| `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the reading is published as separate top-level `now_playing_*` attributes, and `now_playing_via` names the tier (`jellyfin` \| `media_session` \| `app`) |
|
||
| Universal media player `media_dag_running_apps` template | app switcher list with playback states | `running_apps_from_tasks(..., last_known=...)` (`ha_adb_common/running_apps.py`) — enriched with `foreground`/`state`/`last_known_state`/`last_known_title` |
|
||
| Universal player "currently in app X" template | fallback state for no-session apps | the foreground ladder tier (`media_player.py` state property, tier 3.5) |
|
||
| External androidtv_remote entity for commands | remote keys | the protocol-backed `remote` entity (Phase 2) — the external entity remains only as the transitional last command tier, removed in Phase 4 |
|
||
| Last-channel / "recently played" sensors | what was on when the session vanished | the TTL-capped last-known-state cache + `_last_channel` + the audio-playback hint (`connection.py`) |
|
||
|
||
The migration path: point the automations/scripts at the integration's DAG
|
||
attributes (`media_dag_*`) instead of the MQTT sensors, then remove
|
||
`mediasession2mqtt` and the universal-player config entries. No YAML glue is
|
||
needed for the MVP feature set; the appendix exists so retirement can be
|
||
staged per household.
|
||
|
||
**TiviMate (`ar.tvplayer.tv`)** is the documented no-session case (R2): it
|
||
publishes no MediaSession on Shield Android 11. Its visibility comes from the
|
||
foreground tier (tier 3.5) with the audio-playback hint and the
|
||
last-known-state cache — never from a session, never resurrected past the
|
||
state TTL.
|