t66: app names on a device that exposes none

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.
This commit is contained in:
James Mackay committed 2026-10-12 10:54:38 +01:00
1 parent f29f62ddae
commit 7b32de789a
16 files changed
+973 -176

No files matched your search

+28 -8
View File
@@ -154,11 +154,15 @@ def _apps_table_text(
"""The Editable apps table: stored rows first, then discovered apps (t57).
Every app the Shield reports gets a row, so the page opens on the REAL app
list instead of the four curated entries ("it doesn't find all the apps").
list instead of only the built-in names ("it doesn't find all the apps").
The stored rows keep their position, name and icon — the user's edit is
never overwritten by a discovery — and a discovered app the map does not
mention is appended with the device label (or its package id when the label
fetch did not answer) and its heuristic icon.
mention is appended with the name the SAME chain resolves (user map > device
label > curated map) and its heuristic icon. An app whose name is unknown
anywhere is NOT written as ``pkg=pkg``: a row that saves the package id as if
it were a name is the defect the user reported from this very field, so such
an app is offered in the row editor (which says "no name yet" out loud)
instead of being pre-filled here.
The icon column is emitted only for a stored icon, because the discovered
icon is an MDI NAME (``mdi:netflix``) and the icon column's documented
@@ -178,7 +182,11 @@ def _apps_table_text(
if not package or package in seen:
continue
seen.add(package)
name = app.get("name") or package
name = str(app.get("name") or "").strip()
if not name or name == package:
# nothing names this app: the id would be doubled into a "name" that
# is not one (t66). The row editor still offers it for naming.
continue
lines.append(f"{package}={name}")
return "\n".join(lines)
@@ -1287,6 +1295,13 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
"the Shield reported no installed apps — the table keeps your "
"saved rows."
)
if getattr(manager, "app_labels_absent", False):
# Measured, not guessed: the sweep probed and this firmware named
# nothing, so say which names the user is looking at (t66).
return (
f"{len(apps)} apps found — this Shield exposes no app labels over "
"ADB, so names come from the built-in app names and your own rows."
)
return f"{len(apps)} apps found on the Shield — the table is updated."
async def async_step_menu_again(self, user_input=None):
@@ -1530,19 +1545,24 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
"""The apps the device reported, ``{package: {name, icon}}`` (t64).
Straight from the t57 cache — no sweep, no shell. The name may be empty:
the user's Shield exposes no device label at all, and a package whose
``pm dump`` failed falls back to its id, which is NOT a name (see
``_editable_apps``).
the user's Shield exposes no device label at all (t66: the discovery table
no longer repeats the package id as a name), and an app nothing names is
left empty so ``_editable_apps`` can mark it as needing a name.
"""
detected: dict = {}
for app in self._discovered_apps():
package = str(app.get("package") or "").strip()
if not package:
continue
name = str(app.get("name") or "").strip()
if name == package:
# a name that IS the id is not a name (t66): the row editor says
# "(no name yet)" instead of offering the id twice.
name = ""
detected.setdefault(
package,
{
"name": str(app.get("name") or "").strip(),
"name": name,
"icon": str(app.get("icon") or "").strip(),
},
)
+166 -17
View File
@@ -39,11 +39,15 @@ from .const import (
APP_ICON_FALLBACKS,
APP_ICON_GENERIC,
APP_LABEL_EXIT_MARKER,
APP_LABEL_GREP_SHAPES,
APP_LABEL_PROBE_LIMIT,
APP_LABEL_TTL,
APP_LIST_TTL,
APP_SWEEP_CONCURRENCY,
APP_SWEEP_SPACING,
CONF_APP_NAME_MAP,
CONF_LOGCAT_EVENTS,
DEFAULT_APP_DIRECTORY,
LOGCAT_SUPPRESS_WINDOW,
STATE_STORE_TTL,
SYSTEM_MEDIA_PACKAGES,
@@ -54,8 +58,9 @@ from .ha_adb_common import (
icon_for_package,
installed_packages,
is_launchable,
parse_application_label,
parse_app_label,
parse_audio_playback,
resolve_package_name,
)
_LOGGER = logging.getLogger(__name__)
@@ -224,6 +229,16 @@ class ShieldConnectionManager:
# t55: packages whose label fetch is queued or in flight — a render
# during the fetch must not enqueue the same package twice.
self._app_label_inflight: set[str] = set()
# t66: the DEVICE-level "this firmware exposes no labels" memo. Set after
# APP_LABEL_PROBE_LIMIT consecutive probes that RAN and found nothing (one
# label-less package proves nothing — see _dump_label), and it expires
# with APP_LABEL_TTL so a changed device is re-checked within a day.
self._labels_absent_at: float | None = None
self._label_negative_streak: int = 0
# t66: packages whose probe FAILED (transport delivered nothing) and which
# therefore still owe a retry even while the memo above is set — t57's
# "a failed fetch is never cached as an answer" survives the memo.
self._app_label_failed: set[str] = set()
# t57: the discovered installed-apps table — (rows, stamp), or None when
# no sweep has produced a fresh answer. Only a sweep that RAN writes
# here (t62 F1): a failed or unavailable sweep records itself as UNKNOWN
@@ -624,6 +639,68 @@ class ShieldConnectionManager:
self._app_label_requests.add(package)
return None
def _probe_still_owed(self, package: str) -> bool:
"""True when a package's only remaining question is a RETRY (t66).
A probe that FAILED (no poller, shell error, no output at all) records
the package here and caches nothing, so the t57 UNIT 0 guarantee — a
failed fetch is retried, never stamped as an answer — survives the
device-level memo below. A probe that RAN and found no label is not owed
anything: it answered.
"""
return package in self._app_label_failed
def _labels_absent(self) -> bool:
"""The device-level "no labels here" memo, expiring with APP_LABEL_TTL.
A conclusion about a piece of firmware cannot be permanent — the user may
swap the device, update it, or add an app that does carry a label — so
after APP_LABEL_TTL the memo clears itself and one probe re-checks the
device (t66).
"""
if self._labels_absent_at is None:
return False
if self._clock() - self._labels_absent_at > APP_LABEL_TTL:
self._labels_absent_at = None # re-probe once per TTL
self._label_negative_streak = 0
return False
return True
@property
def app_labels_absent(self) -> bool:
"""Public read of the memo (tests, and the options page's own note)."""
return self._labels_absent_at is not None and (
self._clock() - self._labels_absent_at <= APP_LABEL_TTL
)
def _entry_app_name_map(self) -> dict:
"""The user's own apps map from the entry options ({} when unset)."""
options = getattr(self.entry, "options", None) or {}
return dict(options.get(CONF_APP_NAME_MAP) or {})
def _resolved_app_name(self, package: str, device_label=None, user_map=None) -> str:
"""The display name for a discovered app, or "" when nothing names it (t66).
The ONE place the discovery table's name comes from, so it obeys the same
chain as the media player and the Current App sensor
(:func:`~.ha_adb_common.app_label.resolve_package_name`).
"" is deliberate: the row's ``package`` field already carries the id, and
a name that repeats it is exactly the ``pkg=pkg`` row the user reported
from the apps editor. Every consumer already reads an empty name as "this
app needs a name" (the editor says so out loud; the installed-apps sensor
publishes it as-is).
"""
if user_map is None:
user_map = self._entry_app_name_map()
name = resolve_package_name(
package,
user_map=user_map,
device_label=device_label,
curated=DEFAULT_APP_DIRECTORY,
)
return "" if not name or name == package else name
def _known_label(self, package: str) -> tuple[bool, str]:
"""``(known, label)`` from the label cache — "known" = a fetch RAN (t57).
@@ -682,28 +759,66 @@ class ShieldConnectionManager:
except Exception as err:
_LOGGER.debug("protocol device info unavailable: %s", err)
async def _dump_label(self, package: str) -> str | None:
"""One `pm dump` label read: the label, or None for a FAILED fetch.
async def _dump_label(self, package: str, *, on_demand: bool = False) -> str | None:
"""One label read: the label, or None for a FAILED fetch (t66).
The ONE place the dump is issued, shared by the on-demand path
The ONE place the probe is issued, shared by the on-demand path
(:meth:`_fetch_app_label`) and the discovery sweep, so the t57 UNIT 0
distinction — "the command ran and found no label" versus "the
transport delivered nothing" — cannot drift between them. A successful
fetch is also cached here, so both paths benefit from the 24 h cache and
neither double-stamps.
Returns the label (possibly "") when the dump RAN, and None when it did
WHAT CHANGED IN t66, and why the old shape could not work on the user's
Shield: the probe used to ship ``pm dump <pkg> | head -300`` and parse the
answer with ``parse_application_label`` alone. A real dump on that device
is ~2241 lines of dumpsys, so ``head -300`` cut before the package
sections even appeared — the "no label" answer was a truncation
artefact, not a fact about the app. The probe now searches ON THE DEVICE
for every label shape Android prints (``APP_LABEL_GREP_SHAPES``) and only
those lines cross the wire, which is both smaller and honest.
A device that has already answered "no labels" for a run of packages is
NOT probed again (``APP_LABEL_PROBE_LIMIT``, expiring with
``APP_LABEL_TTL``): the sweep's per-package fan-out stops there and a
package the device has never been asked about is answered "" from the
memo, with no shell at all. The two exceptions are deliberate: a package
whose probe FAILED still owes its retry (``_probe_still_owed``), and that
retry is only ever driven by the app ON SCREEN (``on_demand``) — one
shell, once, user-driven, then cached.
The two meanings of "" must stay distinct in the reader's head: a probe
that RAN and found nothing is a cached answer (zero further shells), and a
probe that COULD NOT run is not cached at all — it is recorded as owed and
retried for the app on screen.
Returns the label (possibly "") when the probe RAN, and None when it did
not run or delivered no output at all.
The exit marker is a liveness tell, not a status code (t62 nit b): it sits
after a ``head`` pipeline, and the Android shell that runs this has no
PIPESTATUS, so ``$?`` is ``head``'s status rather than the dump's. Nothing
reads its VALUE — ``parse_application_label`` strips the marker line — and
the only property tested here is that a command which RAN leaves non-blank
output even when the device prints no label. That non-blankness is what
after a pipeline, and the Android shell that runs this has no PIPESTATUS,
so ``$?`` is the last command's status rather than the dump's. Nothing
reads its VALUE — the parser ignores the marker line — and the only
property tested here is that a command which RAN leaves non-blank output
even when the device prints no label. That non-blankness is what
separates "ran, no label" from "the transport delivered nothing", which
this method turns into a cached negative versus a retry.
"""
if self._labels_absent():
if not self._probe_still_owed(package):
# The firmware already answered; a shell here would spend a round
# trip on a settled question (t66). The negative is cached so the
# render path stops asking cycle after cycle.
self.cache_app_label(package, "")
return ""
if not on_demand:
# a retry is not the sweep's job: the fan-out stops here
return ""
_LOGGER.debug(
"retrying the failed label probe for %s on the app now on "
"screen (the device reports no labels, but this one never "
"answered)",
package,
)
poller = self._poller
if poller is None or not self.connected:
_LOGGER.debug(
@@ -711,12 +826,14 @@ class ShieldConnectionManager:
"retry on the next render",
package,
)
self._app_label_failed.add(package)
return None
try:
import shlex
dump = await poller.shell(
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300; "
f"pm dump {shlex.quote(package)} 2>/dev/null | "
f"grep -E {shlex.quote(APP_LABEL_GREP_SHAPES)} | head -40; "
f"echo {APP_LABEL_EXIT_MARKER}$?"
)
except Exception as err:
@@ -726,6 +843,7 @@ class ShieldConnectionManager:
package,
err,
)
self._app_label_failed.add(package)
return None
if dump is None or not dump.strip():
# the transport never delivered output: a FAILED fetch, not an
@@ -735,12 +853,37 @@ class ShieldConnectionManager:
"the next render",
package,
)
self._app_label_failed.add(package)
return None
label = parse_application_label(dump)
# F1: label-less pm dumps negatively cache too (empty label) so a
# later cycle performs zero additional pm dumps for the package.
self._app_label_failed.discard(package) # it answered — nothing owed
label = parse_app_label(dump)
if label:
# This firmware DOES name apps: a run of misses before it proved nothing.
self._label_negative_streak = 0
else:
self._label_negative_streak += 1
if (
self._label_negative_streak >= APP_LABEL_PROBE_LIMIT
and self._labels_absent_at is None
):
# A RUN of label-less answers, each of which really ran: this
# device keeps its labels somewhere we cannot read, so every
# further per-package probe is a shell spent on a settled
# question (t66). The conclusion expires with APP_LABEL_TTL.
self._labels_absent_at = self._clock()
_LOGGER.info(
"%d label probes in a row found no app label — this device "
"exposes none; names now come from the curated map and the "
"user's own apps map for the next %s seconds",
self._label_negative_streak,
APP_LABEL_TTL,
)
# F1: label-less probes negatively cache too (empty label) so a later
# cycle performs zero additional probes for the package.
self.cache_app_label(package, label)
return label
# "" (not None) is the "the probe RAN" answer, even when the device named
# nothing: None is reserved for a fetch that could not run (t57 UNIT 0).
return label or ""
async def _fetch_app_label(self, package: str) -> None:
"""One on-demand `pm dump` label fetch; stamps ONLY a fetch that RAN.
@@ -752,7 +895,7 @@ class ShieldConnectionManager:
"""
self._app_label_inflight.add(package)
try:
await self._dump_label(package)
await self._dump_label(package, on_demand=True)
finally:
self._app_label_inflight.discard(package)
@@ -1049,10 +1192,16 @@ class ShieldConnectionManager:
packages = [p for p in installed_packages(user_output, launcher_output)
if is_launchable(p)]
labels = await self._sweep_labels(packages)
# t66: ONE precedence chain for the table too — the user's own map, then
# the device's label (when it exposes one), then the curated map, then the
# package id. An app nothing can name gets name "" rather than its own id:
# the row already carries the id in ``package``, and writing it twice is
# the ``pkg=pkg`` the user read as a bug in the apps editor.
user_map = self._entry_app_name_map()
rows = [
{
"package": package,
"name": labels.get(package) or package,
"name": self._resolved_app_name(package, labels.get(package), user_map),
"icon": icon_for_package(package, APP_ICON_FALLBACKS, APP_ICON_GENERIC),
}
for package in packages
+48 -4
View File
@@ -3,7 +3,6 @@
# Re-export shared Media DAG constants
from .ha_adb_common.const import ( # noqa: F401
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
@@ -128,6 +127,26 @@ APP_LABEL_TTL = 24 * 3600
# unlikely to collide with a manifest line.
APP_LABEL_EXIT_MARKER = "__NSH_EXIT:"
# t66: the label candidates searched FOR ON THE DEVICE. A real `pm dump` on the
# user's Shield is ~2241 lines of dumpsys (package + activity + meminfo +
# procstats + usagestats + batterystats), and the old fetch shipped
# ``| head -300`` — which cut before the package sections even appeared, so the
# negative was a truncation artefact rather than an answer. Searching on-device
# keeps the wire small AND the answer honest: these are the three shapes Android
# prints for an app's label (see ha_adb_common/app_label.py for what counts as a
# name in each).
APP_LABEL_GREP_SHAPES = "application-label|nonLocalizedLabel|labelRes"
# t66: how many label probes may come back "ran, no label" IN A ROW before the
# device is treated as one that exposes no labels at all (this user's Shield).
# One label-less package is ordinary (system/OEM packages often have none), so a
# single miss proves nothing — but a run of them means the whole firmware keeps
# its labels in resources we cannot read, and every further ``pm dump`` per
# package is a shell spent on a question already answered. The conclusion expires
# with APP_LABEL_TTL so a device change is noticed within a day, and any label
# found anywhere resets the run.
APP_LABEL_PROBE_LIMIT = 5
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
# (same security posture as the core androidtv integration).
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
@@ -142,13 +161,38 @@ CONF_LOGCAT_EVENTS = "logcat_events"
# re-stamped (repeat suppression).
LOGCAT_SUPPRESS_WINDOW = 15.0
# P4-2c BrowseMedia app directory fallback when the options map is empty
# (curated; the packages are the plan's reference apps).
# P4-2c/t66: the curated package -> name table. It is BOTH the BrowseMedia/source
# directory fallback and the third tier of the app-name chain (after the user's
# own map and a device label, before the raw package id) — on a Shield whose
# `pm dump` carries no label at all it is the answer that makes names work.
#
# It is a community-maintained convenience, NOT device truth: package ids are
# stable, display names change with vendors and app updates, and an app this
# table has never heard of falls back to its package id (shown once). Users
# override any entry in Configure > Apps, and that edit always wins. Adding a row
# is deliberately trivial — keep the ids sorted so the diff stays reviewable.
DEFAULT_APP_DIRECTORY = {
"ar.tvplayer.tv": "TiviMate",
"be.digitalia.mediasession2mqtt": "MediaSession2MQTT",
"com.amazon.amazonvideo.livingroom": "Prime Video",
"com.esaba.downloader": "Downloader",
"com.github.damontecres.wholphin": "Wholphin",
"com.netflix.ninja": "Netflix",
"com.google.android.sambadocumentsprovider": "Samba (SMB)",
"com.google.android.speech.pumpkin": "Google Speech Services",
"com.google.android.tts": "Speech Services by Google",
"com.google.android.tvlauncher": "Google TV launcher",
"com.google.android.youtube.tv": "YouTube",
"com.hyperion.grabber": "Hyperion Grabber",
"com.lonelycatgames.Xplore": "X-plore File Manager",
"com.nendo.argosy": "Argosy",
"com.netflix.ninja": "Netflix",
"com.plexapp.mediaserver.smb": "Plex Media Server",
"com.retroarch": "RetroArch",
"com.spocky.projengmenu": "Projectivy Launcher",
"dev.jdtech.jellyfin": "Findroid",
"dev.trooped.tvquickbars": "TV Quick Actions",
"flar2.homebutton": "Button Mapper",
"tv.pluto.android": "Pluto TV",
}
# t57: best-effort icon per discovered app. `pm dump` does NOT expose the
@@ -1,10 +1,15 @@
"""Shared ADB utilities for Home Assistant custom integrations."""
from .adb_client import AdbClientBase, parse_wakefulness
from .app_label import parse_application_label
from .app_label import (
parse_app_label,
parse_application_label,
parse_label_res,
parse_non_localized_label,
resolve_package_name,
)
from .const import (
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
@@ -31,7 +36,6 @@ from .running_apps import running_apps_from_sessions, running_apps_from_tasks
__all__ = [
"ATTR_MEDIA_DAG_JELLYFIN_ENTITY",
"ATTR_MEDIA_DAG_NOW_PLAYING",
"ATTR_MEDIA_DAG_RECEIVER_ENTITY",
"ATTR_MEDIA_DAG_RECEIVER_INPUT",
"ATTR_MEDIA_DAG_ROLE",
@@ -41,7 +45,11 @@ __all__ = [
"PlaybackIntent",
"ensure_adb_key",
"parse_all_media_sessions",
"parse_app_label",
"parse_application_label",
"parse_label_res",
"parse_non_localized_label",
"resolve_package_name",
"parse_audio_playback",
"parse_foreground",
"foreground_from_logcat",
@@ -1,19 +1,55 @@
"""App friendly-name resolution from the package manifest (P5-1).
"""App friendly-name resolution from the package dump (P5-1, t66).
Parses `pm dump <package>` output for the ``application-label`` line — the
default-locale variant; localized suffixed variants
(``application-label-<lang>:``) are skipped, and multi-label blocks across
locales are tolerated (first default-locale label wins), mirroring the
tolerant pattern of the androidtv-adb core helper.
Parses the label candidate LINES of a `pm dump <package>` / `dumpsys package`
answer. Three shapes are searched, in priority order, because Android firmware
prints different ones (and one of them on this user's Shield prints nothing at
all):
1. ``application-label: 'Netflix'`` — the AAPT-style manifest label, with its
localized suffixed variants (``application-label-<lang>:``) as a fallback.
2. ``nonLocalizedLabel=Netflix`` — the ApplicationInfo field. It is usually
``null`` on a TV device (the label comes from a resource), so a null/empty
value is not a name.
3. ``labelRes=0x7f0f0001`` — a RESOURCE ID, not text. A dump can only name the
app here if it prints a quoted string, so a numeric/hex id is deliberately
NOT used as a name: we would be showing ``0x7f0f0001`` as if it were a name.
Nothing here is a guess about which line exists: every branch is anchored on the
literal prefix Android prints, and a miss returns None so the caller falls
through to the curated map (the answer on a device with no labels at all).
Also exports the ONE package -> name precedence chain, so the media player, the
Current App sensor, the discovery table and the options editor cannot drift:
user map > device label > curated map > raw package id.
"""
import re
#: ``application-label: 'X'`` / ``application-label-de: "X"`` — the AAPT form.
_APPLICATION_LABEL_RE = re.compile(
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
)
#: ``nonLocalizedLabel=X`` — bare word, quoted string, or ``null``.
_NON_LOCALIZED_LABEL_RE = re.compile(
r"nonLocalizedLabel=(?:'([^']*)'|\"([^\"]*)\"|([^\s,}]+))"
)
#: ``labelRes=0x7f0f0001`` — only a QUOTED value is text; an id is not a name.
_LABEL_RES_RE = re.compile(r"labelRes=(?:'([^']*)'|\"([^\"]*)\")")
#: Values a dump prints that are not names.
_NOT_A_NAME = ("", "null", "0x0", "0")
def _clean(value: str | None) -> str | None:
"""A candidate label, or None when it is empty/``null``."""
text = (value or "").strip()
if text.lower() in _NOT_A_NAME:
return None
return text
def parse_application_label(dump: str) -> str | None:
"""The package's default-locale application label, or None.
@@ -24,7 +60,92 @@ def parse_application_label(dump: str) -> str | None:
if not dump:
return None
for match in _APPLICATION_LABEL_RE.finditer(dump):
label = (match.group(1) or match.group(2) or "").strip()
if label and label not in ("null", ""):
label = _clean(match.group(1) or match.group(2))
if label:
return label
return None
def parse_non_localized_label(dump: str) -> str | None:
"""The ``nonLocalizedLabel=`` value, or None (t66).
``null`` is the common case on a TV and is NOT a name.
"""
if not dump:
return None
for match in _NON_LOCALIZED_LABEL_RE.finditer(dump):
label = _clean(match.group(1) or match.group(2) or match.group(3))
if label:
return label
return None
def parse_label_res(dump: str) -> str | None:
"""A QUOTED ``labelRes=`` value, or None (t66).
``labelRes=0x7f0f0001`` is a resource id — showing it as an app name would be
worse than showing the package id, so only a quoted string counts.
"""
if not dump:
return None
for match in _LABEL_RES_RE.finditer(dump):
label = _clean(match.group(1) or match.group(2))
if label:
return label
return None
def parse_app_label(dump: str) -> str | None:
"""The best device label in ``dump``, trying every shape Android prints.
Priority follows how specific the shape is: the AAPT manifest label first,
then the ApplicationInfo field, then a quoted resource label. None means the
device did not name this app — which, on a device whose dump carries no
labels at all, is the definitive answer that sends the caller to the curated
map (t66).
"""
return (
parse_application_label(dump)
or parse_non_localized_label(dump)
or parse_label_res(dump)
)
def resolve_package_name(
package: str | None,
*,
user_map=None,
device_label=None,
curated=None,
) -> str | None:
"""THE package -> display-name chain (t66): one order, every surface.
1. the user's explicit apps map (their own edit always wins);
2. the device's own label, when the device exposes one;
3. the curated community map (the answer on a device with no labels: see
``DEFAULT_APP_DIRECTORY``);
4. the raw package id, shown ONCE.
``device_label`` may be a VALUE or a CALLABLE. A callable is looked up only
after the user map has missed, which is what keeps tier 2 LAZY: the manager's
``app_label()`` accessor enqueues an on-demand device fetch on a miss, so
calling it for an app the user has already named would spend a shell to learn
something nobody reads.
Returns None only for an empty package, so a caller can always display
something. The id is never doubled into a ``pkg=pkg`` row: tier 4 IS the id,
and there is no tier that formats it as a name as well.
"""
if not package:
return None
mapped = (user_map or {}).get(package)
if mapped:
return str(mapped)
raw_label = device_label(package) if callable(device_label) else device_label
label = _clean(raw_label)
if label:
return label
curated_name = (curated or {}).get(package)
if curated_name:
return str(curated_name)
return package
@@ -17,9 +17,11 @@ ATTR_MEDIA_DAG_RECEIVER_INPUT = "media_dag_receiver_input"
# Jellyfin media_player entity for rich metadata
ATTR_MEDIA_DAG_JELLYFIN_ENTITY = "media_dag_jellyfin_entity"
# Now-playing dict: {state, title, media_type, series?, season?, episode?,
# duration?, position?, thumbnail?, artist?, album?, media_info?, source?}
ATTR_MEDIA_DAG_NOW_PLAYING = "media_dag_now_playing"
# t65 flattened the now-playing reading into separate top-level ``now_playing_*``
# attributes on the media player (``now_playing_via`` names the tier), so the single
# nested ``media_dag_now_playing`` key this file used to define is GONE — the last
# reference to it was removed in t66's cleanup. The flattened names live in
# ``custom_components/nvidia_shield/media_player.py`` (``now_playing_attribute_names``).
# List of running app dicts: [{package, state, title?, artist?}, ...]
ATTR_MEDIA_DAG_RUNNING_APPS = "media_dag_running_apps"
+22 -15
View File
@@ -116,7 +116,11 @@ from .const import (
TIVIMATE_PACKAGE,
command_scripts,
)
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
from .ha_adb_common import (
PlaybackIntent,
resolve_package_name,
running_apps_from_tasks,
)
from .jellyfin import fetch_session_media_info
from .power import discover_power_siblings
@@ -377,26 +381,29 @@ def resolve_app_name(
package: str | None,
app_name_map,
app_label,
curated=None,
) -> str | None:
"""Resolve a package id to a friendly name (t40; entity tier removed t41).
"""Resolve a package id to a friendly name (t40; curated tier t66).
The single chain behind ``media_player.app_name`` and the Current App
sensor:
sensor, delegating to ``ha_adb_common.app_label.resolve_package_name`` so the
discovery table and the options editor cannot drift from it:
1. the user's explicit apps map (``CONF_APP_NAME_MAP``);
2. the cached device-manifest label (``manager.app_label`` — a cache miss
enqueues the on-demand fetch and falls through);
3. the raw package text — a bare or unknown package falls back to itself,
never to None.
2. the cached device label (``manager.app_label`` — a cache miss enqueues the
on-demand fetch and falls through);
3. the curated table (``DEFAULT_APP_DIRECTORY``) — the tier that names apps on
a Shield whose ``pm dump`` exposes no label at all;
4. the raw package text — a bare or unknown package falls back to itself,
never to None, and it is shown ONCE (no ``pkg=pkg`` anywhere).
"""
if package:
mapped = (app_name_map or {}).get(package)
if mapped:
return mapped
label = app_label(package) if app_label is not None else None
if label:
return label
return package or None
return resolve_package_name(
package,
user_map=app_name_map,
# the callable keeps the device tier LAZY: the user's map answers first
device_label=app_label,
curated=DEFAULT_APP_DIRECTORY if curated is None else curated,
)
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
+4 -3
View File
@@ -124,9 +124,10 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
"""The resolved friendly name (t40), not the raw package id.
Exactly the ``media_player.app_name`` chain via the shared
``resolve_app_name``: apps map → cached manifest label → the package
text itself. A bare or unknown package shows its package text rather
than None, like the media player's app_name.
``resolve_app_name``: apps map → cached device label → the built-in app
names (t66) → the package text itself. A bare or unknown package shows its
package text rather than None, like the media player's app_name, and that
text is the id ONCE — never a ``pkg=pkg`` pair.
This is why ``_package`` must be the CURRENT app and the label fetch
must be driven off it (t55): the device-manifest label is resolved on