diff --git a/README.md b/README.md index 616ae31..24ddb95 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,10 @@ remote protocol is the fast push/command path: 4. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active. 5. **ADB media session** — `playing` / `paused` / `buffering` for apps that publish a session (foreground app preferred). `buffering` is the media - session's own PlaybackState 6 (`state=PlaybackState {state=6, …}`), and a - buffering session advertises the playback controls exactly like `playing`. + session's PlaybackState **6** (`state=PlaybackState {state=6, …}`) — the + stock-Android buffering value, **inferred** from that line shape: no captured + fixture here contains `state=6` and no real device was available. A buffering + session advertises the playback controls exactly like `playing`. 6. **Idle** — the device is powered (protocol `is_on` true, ADB awake, or the power plug on) and nothing is playing, or ADB reports it asleep (standby). A powered Shield with an app open and nothing playing reads `idle`. @@ -416,12 +418,63 @@ A script that fails surfaces as a translated error on the service call that trig it (the command is not retried over protocol/ADB), because a silent no-op is the one outcome that leaves you guessing. +### App discovery (what is actually installed) + +The Configure page can ask the Shield which apps it has, so the **Apps** table does not +start from a four-entry guess. Pick **Discover installed apps** on the Configure menu +and the table is rebuilt from two ADB queries: + +| Command | What it returns | How it is treated | +| --- | --- | --- | +| `pm list packages -3` | every **third-party** (user-installed) package, one `package:` per line | all of them become rows | +| `cmd package resolve-activity --brief -c android.intent.category.HOME` | the **active launcher** (`com.google.android.tvlauncher/…MainActivity`) | its package is appended | + +System apps are **not** enumerated — a Shield ships a few hundred of them and they are +not launchable apps — with the one exception that matters: the active launcher, which +is a system app yet is exactly what the user sees. Nothing else is filtered, so an app +is missing only if Android itself does not report it. + +Names come from the same cached path the Current App sensor uses: a `pm dump` per +package, parsed for `application-label`, cached for 24 h. A full sweep runs the dumps +in a small burst (4 at a time, 50 ms apart), which finishes a typical Shield in +seconds instead of the minutes a strictly serialised sweep would take. A label fetch +that **fails is not cached**, so that app shows its package id in this table and the +next sweep tries again — this is the same rule as the entity path, and it is why a +transient ADB hiccup no longer sticks. + +Icons are **best effort and explicitly heuristic**: `pm dump` does not expose an app's +icon resource (an extracted APK's icon is a bitmap only its own code can resolve), so +there is no device icon to read. Each app gets a curated `package → mdi:` icon where we +know one — Netflix, YouTube, Kodi, Plex, Spotify, the launcher, TiviMate, Wholphin — +and `mdi:television` otherwise. The icon never enters your **icon column**: that column +is a picture URL or a `camera.*` entity and feeds `entity_picture`, so writing an +`mdi:` name there would put a value the media player cannot use into your own map. + +Discovery never runs on a state poll: it happens when you press the row, and the result +is cached for 6 h. With ADB off, or unreachable, the table simply shows the rows you +wrote yourself and the menu says why. + +**For dashboards** the same table is a sensor: `sensor._installed_apps`, whose +state is the app **count** and whose `installed_apps` attribute is the list of +`{package, name, icon}`. Its `discovery` attribute says `ok`, `pending`, `not_connected` +or `adb_disabled`. It only exists for ADB-enabled entries, and it is cheap to refresh — +`homeassistant.update_entity` on it re-runs the sweep only when the cache has expired, +while the Configure row forces one. + +```yaml +# every installed app, with the name this integration would show +{{ state_attr('sensor.living_room_shield_installed_apps', 'installed_apps') }} + | map(attribute='name') | list }} +``` + ### Sources (the app list in a dropdown) The media player's source list is your **Apps** map — the same map the Browse tree and app-name resolution use. With no map configured it falls back to a small built-in list (TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has -something to show: +something to show. Discovery fills the **Configure** table, not this fallback: the +dropdown stays exactly the map you saved, so what a card offers is always what you +chose. An app that discovery found and you want in the dropdown is one row to add. ```yaml # a media-player card, or anything that reads source_list diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index a47640d..92e1b1f 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -130,6 +130,41 @@ def _app_map_from_text( return app_map +def _apps_table_text( + stored_map: dict, stored_icons: dict, discovered: list[dict] +) -> str: + """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"). + 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. + + 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 + values are a picture URL or a ``camera.*`` entity that feeds + ``entity_picture``. Writing an ``mdi:`` string there would put a value the + media player cannot use into the user's own map — the icon is offered in the + table's description instead, and in the installed-apps sensor's attributes. + """ + lines: list[str] = [] + seen: set[str] = set() + for package, name in (stored_map or {}).items(): + seen.add(package) + icon = (stored_icons or {}).get(package) + lines.append(f"{package}={name}|{icon}" if icon else f"{package}={name}") + for app in discovered or []: + package = app.get("package") + if not package or package in seen: + continue + seen.add(package) + name = app.get("name") or package + lines.append(f"{package}={name}") + return "\n".join(lines) + + def _options_field_values(options: dict | None) -> dict: """Entry options as FRONTEND-SERIALIZABLE field values (t56). @@ -1101,12 +1136,69 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): manager reads ``next_step_id`` off the menu result and calls the chosen step directly. Returning the menu here is what every sub-screen comes back to. + + ``discover`` is the explicit refresh (t57): it runs the ADB sweep and + reports what it found, so the app table is never built by accident and + nothing sweeps on a state poll. """ return self.async_show_menu( step_id="init", - menu_options=["connection", "receiver", "apps", "scripts", "finish"], + menu_options=[ + "connection", + "receiver", + "apps", + "discover", + "scripts", + "finish", + ], + description_placeholders=self._menu_placeholders(), ) + def _menu_placeholders(self) -> dict: + """What the menu says about the current discovery state (t57).""" + apps = self._discovered_apps() + if apps: + return {"discovery": f"{len(apps)} apps found on the Shield"} + manager = getattr(self.config_entry, "runtime_data", None) if ( + self.config_entry is not None + ) else None + if manager is not None and not getattr(manager, "connected", False): + return {"discovery": "no ADB connection — nothing discovered yet"} + return {"discovery": "not discovered yet — use Discover installed apps"} + + async def async_step_discover(self, user_input=None): + """Discover the apps installed on the Shield over ADB (t57). + + A menu ROW, not a form: Home Assistant dispatches ``next_step_id`` to + this method with no user input, it awaits the manager's bounded sweep, + and the menu comes back with a line saying what was found. Running it + from a row keeps the sweep explicit (never a per-poll cost) and gives it + a place to report a failure without a new field. + """ + await self._run_discovery() + return await self.async_step_init() + + async def _run_discovery(self) -> None: + """Sweep the device, swallowing every failure into the menu's note. + + Discovery is a convenience: when ADB is off or down, or the sweep finds + nothing, the menu must still render and the table must still work on the + user's own rows. So nothing here raises — a failure simply leaves the + discovered list empty, which is what the placeholders report. + """ + entry = self.config_entry + manager = getattr(entry, "runtime_data", None) if entry is not None else None + if manager is None: + store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {} + manager = store.get(getattr(entry, "entry_id", None)) + sweep = getattr(manager, "async_refresh_installed_apps", None) + if sweep is None: + return + try: + await sweep(force=True) + except Exception as err: # noqa: BLE001 - a failed sweep is not an error + _LOGGER.debug("app discovery from the options page failed: %s", err) + async def async_step_menu_again(self): """Return to the options menu after a sub-screen saved (t56). @@ -1285,22 +1377,56 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): return self._show_sub_form("receiver", schema_dict, errors) async def async_step_apps(self, user_input=None): - """Apps: the editable apps table — one row per app, blank when empty.""" + """Apps: the editable apps table — one row per discovered + stored app.""" errors: dict = {} if user_input is not None and self._apply_submission(dict(user_input), errors): return await self.async_step_menu_again() return self._show_apps_form(errors) + def _discovered_apps(self) -> list[dict]: + """The apps the device reported, from the last background sweep (t57). + + Sources, in order: the entry's ``runtime_data`` (where the integration + puts its connection manager) and then the domain store, both of which + the options flow can reach through ``hass``. Never triggers a sweep: + this is a pure read of the manager's TTL cache, so rendering a form + cannot issue shells. When nothing has been discovered (ADB off or down, + or the first sweep still running) the list is empty and the table shows + the user's own rows — the empty state, not a crash. + """ + entry = self.config_entry + manager = getattr(entry, "runtime_data", None) if entry is not None else None + if manager is None: + store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {} + manager = store.get(getattr(entry, "entry_id", None)) + if manager is None or not hasattr(manager, "installed_apps"): + return [] + return manager.installed_apps() + def _show_apps_form(self, errors=None): + stored = self._options_dict schema_dict = { # One ROW per line, exactly the stored shape and the documented - # editor format: `package=name` with an optional `|icon`. The field - # is pre-filled with the serialized map (never the dict), and the - # empty state is simply blank — no discovery data exists yet in this - # task, so there are no rows to invent and nothing to crash on. + # editor format: `package=name` with an optional `|icon`. The rows + # are the user's own map PLUS everything discovery found on the + # device, and the field carries TEXT in every case (never the + # stored dict — that was the [object Object] bug). vol.Optional(CONF_APP_NAME_MAP): str, } - return self._show_sub_form("apps", schema_dict, errors) + discovered = self._discovered_apps() + suggested = _apps_table_text( + stored.get(CONF_APP_NAME_MAP) or {}, + stored.get(CONF_APP_ICON_MAP) or {}, + discovered, + ) + icons = sorted({app.get("icon") for app in discovered if app.get("icon")}) + return self._show_sub_form( + "apps", + schema_dict, + errors, + override={CONF_APP_NAME_MAP: suggested}, + placeholders={"icons": ", ".join(icons) or "none known"}, + ) async def async_step_scripts(self, user_input=None): """Scripts: per-command overrides and the channel digit behaviour.""" @@ -1320,23 +1446,42 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): # ── rendering ── - def _show_sub_form(self, step_id: str, schema_dict: dict, errors: dict | None): + def _show_sub_form( + self, + step_id: str, + schema_dict: dict, + errors: dict | None, + override: dict | None = None, + placeholders: dict | None = None, + ): """Render a sub-screen with only ITS suggested values — never a dict. ``_options_field_values`` is what serializes the apps map for the wire, and the script selectors are suggested from the normalized ``command_scripts`` dict — the same accessor the service layer reads, so the page cannot offer a script the runtime would ignore. + + ``override`` replaces individual suggested values for a screen whose + suggestion is not simply the stored option (the Apps table is the + stored map MERGED with discovery, t57). ``placeholders`` feeds the + step's ``description_placeholders``, which is how a screen reports + something long — the Discover row's outcome — without inventing a field + for it. """ suggested = _options_field_values(self._options_dict) for command, script in command_scripts(self._options_dict).items(): suggested[f"{SCRIPT_FIELD_PREFIX}{command}"] = script + suggested.update(override or {}) + kwargs: dict = {} + if placeholders: + kwargs["description_placeholders"] = dict(placeholders) return self.async_show_form( step_id=step_id, data_schema=self.add_suggested_values_to_schema( vol.Schema(schema_dict), suggested ), errors=errors or {}, + **kwargs, ) diff --git a/custom_components/nvidia_shield/sensor.py b/custom_components/nvidia_shield/sensor.py index d6c55e7..c3957ad 100644 --- a/custom_components/nvidia_shield/sensor.py +++ b/custom_components/nvidia_shield/sensor.py @@ -12,6 +12,7 @@ from homeassistant.helpers.event import async_track_state_change_event from .connection import ShieldConnectionManager from .const import ( + CONF_ADB_ENABLED, CONF_APP_NAME_MAP, CONF_JELLYFIN_ENTITY, DOMAIN, @@ -28,6 +29,10 @@ async def async_setup_entry( ) -> None: """Set up the Nvidia Shield sensor entities.""" entities = [NvidiaShieldCurrentAppSensor(hass, entry)] + if entry.options.get(CONF_ADB_ENABLED, True): + # discovery is an ADB feature; with the transport off there is nothing + # to enumerate and the entity would only ever report "unavailable" + entities.append(NvidiaShieldAppsSensor(hass, entry)) if entry.options.get(CONF_JELLYFIN_ENTITY): entities.append(NvidiaShieldNowPlayingSensor(hass, entry)) @@ -41,7 +46,9 @@ class NvidiaShieldSensorBase(SensorEntity): _attr_has_entity_name = True _attr_should_poll = False # G12 (t30): poll-driven attributes never belong in the database - _unrecorded_attributes = frozenset({"app_id", "app_name", "playback_state"}) + _unrecorded_attributes = frozenset( + {"app_id", "app_name", "playback_state", "installed_apps"} + ) def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.hass = hass @@ -148,6 +155,85 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase): return attrs +class NvidiaShieldAppsSensor(NvidiaShieldSensorBase): + """The apps installed on the Shield (t57) — a dashboard-facing surface. + + The state is the app COUNT and the ``installed_apps`` attribute is the + table: ``[{package, name, icon}, ...]``, exactly what the Configure page's + Apps sub-screen edits. It is the answer to "it doesn't find all the apps": + the list comes from the device (``pm list packages -3`` plus the active + launcher), labelled through the cached ``pm dump`` path. + + Only ADB-enabled entries get one. Discovery is a handful of shells, so it + runs once when the entity is added and then only when the table has gone + stale: ``async_update`` goes through the manager's TTL cache, so a poll on a + fresh table issues NO shell. On top of that the user can ask for a refresh + with the standard ``homeassistant.update_entity`` action, and the Configure + page runs the same sweep from its discovery row. With ADB off, or + unreachable, the attributes say why and the state is 0 rather than raising. + """ + + _attr_translation_key = "installed_apps" + _attr_icon = "mdi:apps" + # Polling is what makes the standard `homeassistant.update_entity` action + # work for it — the user-facing "refresh the app list" trigger. The poll is + # free while the table is fresh: the manager returns the cache and issues no + # shell until APP_LIST_TTL expires. + _attr_should_poll = True + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + super().__init__(hass, entry) + self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_installed_apps" + + def _manager(self) -> ShieldConnectionManager | None: + manager = getattr(self._entry, "runtime_data", None) + return manager if isinstance(manager, ShieldConnectionManager) else None + + async def async_added_to_hass(self) -> None: + manager = self._manager() + if manager is not None: + self._unsub_listeners.append(manager.register_listener(self._handle_store_update)) + # one discovery per entity lifetime; a load is already a wake-up moment, + # and nothing here runs on a state poll + self.hass.async_create_task(self.async_update()) + + @callback + def _handle_store_update(self) -> None: + if self.hass is not None: + self.async_write_ha_state() + + async def async_update(self) -> None: + """Refresh the table on demand (explicitly requested only).""" + manager = self._manager() + if manager is None: + return + await manager.async_refresh_installed_apps() + + @property + def native_value(self) -> int: + manager = self._manager() + return len(manager.installed_apps()) if manager is not None else 0 + + @property + def extra_state_attributes(self) -> dict: + """The table itself, plus why it may be empty (never a crash).""" + manager = self._manager() + if manager is None: + return {"installed_apps": [], "discovery": "unavailable"} + apps = manager.installed_apps() + attrs = {"installed_apps": apps} + if not apps: + if not self._entry.options.get(CONF_ADB_ENABLED, True): + attrs["discovery"] = "adb_disabled" + elif not manager.connected: + attrs["discovery"] = "not_connected" + else: + attrs["discovery"] = "pending" + else: + attrs["discovery"] = "ok" + return attrs + + class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase): """What's currently playing via Jellyfin.""" diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index 57b5860..a41dbb3 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -142,11 +142,12 @@ "step": { "init": { "title": "NVIDIA Shield settings", - "description": "Settings for this Shield. Saving reloads the integration so the changes take effect.", + "description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.", "menu": { "connection": "Main setup", "receiver": "Receiver", "apps": "Apps", + "discover": "Discover installed apps", "scripts": "Scripts", "finish": "Save and close" }, @@ -308,6 +309,9 @@ }, "now_playing": { "name": "Now playing" + }, + "installed_apps": { + "name": "Installed apps" } } } diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index 57b5860..a41dbb3 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -142,11 +142,12 @@ "step": { "init": { "title": "NVIDIA Shield settings", - "description": "Settings for this Shield. Saving reloads the integration so the changes take effect.", + "description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.", "menu": { "connection": "Main setup", "receiver": "Receiver", "apps": "Apps", + "discover": "Discover installed apps", "scripts": "Scripts", "finish": "Save and close" }, @@ -308,6 +309,9 @@ }, "now_playing": { "name": "Now playing" + }, + "installed_apps": { + "name": "Installed apps" } } }