refactor(config): drop the androidtv media_player entity path end-to-end (t41)
The user ruling: this integration REPLACES both old integrations, so nothing may depend on an androidtv/androidtv_remote entity anymore. Removed: * the setup-form field + its entity selector (CONF_ANDROIDTV_ENTITY is no longer offered, only read by the migration that drops it); * the entity-derived unique_id derivation (_unique_id_from_atv_device, t29 G2) and, with it, every registry lookup for another integration's devices — the user step now sets NO unique_id at all; the protocol MAC learned during pairing is the identity; * the Jellyfin auto-detect's second tier (the atv entity's friendly_name) — it now uses the entry name the user typed or the device name from discovery; * the app-name and app-id tiers (media_player.app_name/app_id + the shared resolve_app_name chain, the Current App sensor, the sensor's state-change listener and its `_androidtv_state` helper) — the manager's store is the only source; * the "transitional external entity" command tier in every path: `_cmd` and the pan-media_player service call are gone, and so is the fallback in _media_cmd, _remote_key_cmd, the §7 channel-digit sequence and the intent self-heal retry (an intent no transport served now surrenders to the truth instead of delegating); * the entity-derived power signal (`_on_source_change`'s "device off clears the intent" check no longer watches another integration's entity; the power LADDER — protocol is_on → ADB wake → power switch — is unchanged). turn_on/turn_off are native now: they send the protocol POWER key and, like the remote entity, send nothing when the known state already matches (no superfluous toggle). An ADB-only entry (protocol skipped) has no power write path — the manager owns the socket and exposes no device-keyevent, which is what the old delegation used to cover. helpers.discover_adb_host — the one helper that derived a host from another integration's config entry — is deleted; button.py (granted to this task) reads the entry's own address fields instead of the entity, so the Pair ADB button keeps working. Present-day entries that still carry the key are unaffected (nothing reads it); the v3 migration that drops it lands with the host unification. Verified: 309 passed / 2 skipped, 310 / 1 with androidtvremote2, ruff clean, drift engine exit 0 on HEAD (13 modules, 58 imports, 35 call arguments, 6 awaited HA calls) and 16 passed in the real-HA drift suite. The stub harness's legacy service recorder became a watchdog: any HA service call from the media player now fails the routing tests instead of being the expected outcome.
This commit is contained in:
1 parent
60d884b76a
commit
86aa2827a2
9 files changed
+247
-351
No files matched your search
@@ -24,7 +24,6 @@ from .const import (
|
|||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_HOST,
|
CONF_ADB_HOST,
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
|
||||||
CONF_PROTOCOL_ENABLED,
|
CONF_PROTOCOL_ENABLED,
|
||||||
CONF_PROTOCOL_HOST,
|
CONF_PROTOCOL_HOST,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
@@ -32,7 +31,7 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_ADB_COMMAND,
|
SERVICE_ADB_COMMAND,
|
||||||
)
|
)
|
||||||
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
from .helpers import protocol_cert_paths, resolve_adb_config
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -178,15 +177,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
"""Manager protocol_settings when the transport is enabled & resolvable.
|
"""Manager protocol_settings when the transport is enabled & resolvable.
|
||||||
|
|
||||||
Falls back to the androidtv-remote host discovery (the ADB host fallback)
|
The address is the entry's own (t41): the protocol field, else the ADB
|
||||||
until the user pairs, after which the explicit protocol host persists.
|
field — both name the same Shield. Nothing is derived from another
|
||||||
|
integration.
|
||||||
"""
|
"""
|
||||||
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
host = entry.options.get(CONF_PROTOCOL_HOST) or discover_adb_host(
|
host = entry.options.get(CONF_PROTOCOL_HOST) or entry.options.get(CONF_ADB_HOST)
|
||||||
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
|
||||||
)
|
|
||||||
if not host:
|
if not host:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Protocol enabled for entry %s but no host resolvable — continuing "
|
"Protocol enabled for entry %s but no host resolvable — continuing "
|
||||||
|
|||||||
@@ -13,11 +13,10 @@ from .const import (
|
|||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_HOST,
|
CONF_ADB_HOST,
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_PROTOCOL_HOST,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
from .helpers import discover_adb_host
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -55,8 +54,10 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
|||||||
|
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
||||||
host = self._entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
# t41: the address is ours — never derived from another integration's
|
||||||
self.hass, self._entry.data.get(CONF_ANDROIDTV_ENTITY)
|
# entity. The ADB field leads, then the protocol field (same Shield).
|
||||||
|
host = self._entry.options.get(CONF_ADB_HOST) or self._entry.options.get(
|
||||||
|
CONF_PROTOCOL_HOST
|
||||||
)
|
)
|
||||||
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import logging
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers import device_registry as dr
|
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers import selector
|
from homeassistant.helpers import selector
|
||||||
|
|
||||||
@@ -19,7 +18,6 @@ from .const import (
|
|||||||
CONF_ADB_HOST,
|
CONF_ADB_HOST,
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
|
||||||
CONF_APP_ICON_MAP,
|
CONF_APP_ICON_MAP,
|
||||||
CONF_APP_NAME_MAP,
|
CONF_APP_NAME_MAP,
|
||||||
CONF_CHANNEL_DIGIT_DELAY,
|
CONF_CHANNEL_DIGIT_DELAY,
|
||||||
@@ -37,7 +35,7 @@ from .const import (
|
|||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
from .helpers import discover_adb_host, protocol_cert_paths
|
from .helpers import protocol_cert_paths
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -53,34 +51,6 @@ def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
|
|||||||
return list(input_map.keys()) if isinstance(input_map, dict) else []
|
return list(input_map.keys()) if isinstance(input_map, dict) else []
|
||||||
|
|
||||||
|
|
||||||
def _unique_id_from_atv_device(hass, androidtv_entity: str) -> str | None:
|
|
||||||
"""A stable unique_id from the atv entity's device MAC (t29 G2).
|
|
||||||
|
|
||||||
The device registry holds CONNECTION_NETWORK_MAC for Android TV
|
|
||||||
entities configured with a host — far stronger than the renameable
|
|
||||||
entity_id. None (→ no unique_id at all, not an entity-derived
|
|
||||||
placeholder) when the entity is unregistered or carries no MAC.
|
|
||||||
"""
|
|
||||||
if not androidtv_entity:
|
|
||||||
return None
|
|
||||||
registry = er.async_get(hass)
|
|
||||||
entity = registry.entities.get(androidtv_entity)
|
|
||||||
device_id = getattr(entity, "device_id", None) if entity else None
|
|
||||||
device = (
|
|
||||||
dr.async_get(hass).async_get(device_id) if device_id else None
|
|
||||||
)
|
|
||||||
if device is None:
|
|
||||||
return None
|
|
||||||
if isinstance(dr, dict): # stub shape guard
|
|
||||||
return None
|
|
||||||
for connection in device.connections:
|
|
||||||
# connections are {connection_type, value} pairs; only a REAL mac
|
|
||||||
# value is usable (an empty placeholder is skipped)
|
|
||||||
if connection[0] == "mac" and connection[1]:
|
|
||||||
return connection[1]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
||||||
"""Auto-detect the Jellyfin media_player entity for a device."""
|
"""Auto-detect the Jellyfin media_player entity for a device."""
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
@@ -169,7 +139,7 @@ def _host_field_key(field: str, host: str):
|
|||||||
|
|
||||||
|
|
||||||
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
||||||
"""User-step schema — the androidtv entity is optional (standalone).
|
"""User-step schema — every field belongs to THIS integration (t41).
|
||||||
|
|
||||||
``defaults`` pre-fills name/hosts from a zeroconf discovery event (or a
|
``defaults`` pre-fills name/hosts from a zeroconf discovery event (or a
|
||||||
YAML seed hint); the ADB port is pre-filled with the standard 5555 so it
|
YAML seed hint); the ADB port is pre-filled with the standard 5555 so it
|
||||||
@@ -182,7 +152,6 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
|||||||
port = DEFAULT_ADB_PORT
|
port = DEFAULT_ADB_PORT
|
||||||
schema_dict = {
|
schema_dict = {
|
||||||
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
|
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
|
||||||
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
|
||||||
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
||||||
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
||||||
}
|
}
|
||||||
@@ -207,9 +176,6 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
|||||||
def _build_selectors():
|
def _build_selectors():
|
||||||
"""Build reusable selector instances."""
|
"""Build reusable selector instances."""
|
||||||
return {
|
return {
|
||||||
"androidtv": selector.EntitySelector(
|
|
||||||
selector.EntitySelectorConfig(domain="media_player", device_class="tv")
|
|
||||||
),
|
|
||||||
"media_player": selector.EntitySelector(
|
"media_player": selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(domain="media_player")
|
selector.EntitySelectorConfig(domain="media_player")
|
||||||
),
|
),
|
||||||
@@ -243,51 +209,32 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
async def async_step_user(self, user_input=None):
|
async def async_step_user(self, user_input=None):
|
||||||
"""Step 1: Basic setup.
|
"""Step 1: Basic setup.
|
||||||
|
|
||||||
Standalone setup (Phase 3): any one of the three state sources is
|
We own the whole stack (t41), so the two state sources are ours: the
|
||||||
enough — the Android TV Remote entity, the Nvidia Remote Protocol
|
Nvidia Remote Protocol (pairing) and ADB media-session mode
|
||||||
(pairing), or ADB media-session mode (CONF_ADB_ENABLED). The
|
(CONF_ADB_ENABLED). The ``no_state_source`` error fires only when
|
||||||
``no_state_source`` error fires only when none of them is present.
|
neither is enabled.
|
||||||
"""
|
"""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY)
|
has_source = bool(user_input.get(CONF_PROTOCOL_ENABLED)) or bool(
|
||||||
|
user_input.get(CONF_ADB_ENABLED)
|
||||||
has_source = (
|
|
||||||
bool(androidtv_entity)
|
|
||||||
or bool(user_input.get(CONF_PROTOCOL_ENABLED))
|
|
||||||
or bool(user_input.get(CONF_ADB_ENABLED))
|
|
||||||
)
|
)
|
||||||
if not has_source:
|
if not has_source:
|
||||||
errors["base"] = "no_state_source"
|
errors["base"] = "no_state_source"
|
||||||
return self._show_user_form(errors)
|
return self._show_user_form(errors)
|
||||||
|
|
||||||
# t29 G2: the androidtv entity_id is a renameable reference —
|
# t41: nothing here derives a unique_id from another integration.
|
||||||
# never a unique_id. A sqlite/registry MAC is preferred (the
|
# The entry's identity is the protocol MAC learned during pairing
|
||||||
# atv entity's device holds CONNECTION_NETWORK_MAC); without a
|
# (`async_set_unique_id(mac)` in the pairing step) or the config
|
||||||
# resolvable MAC the entry gets NO unique_id (the same
|
# entry itself — never a renameable entity id (t29 G2's
|
||||||
# uniqueness-less semantics _async_handle_discovery_without_
|
# atv-device derivation went with the entity path).
|
||||||
# unique_id applies to MAC-less discovery) — renames can never
|
|
||||||
# break uniqueness.
|
|
||||||
fallback_unique_id = _unique_id_from_atv_device(
|
|
||||||
self.hass, androidtv_entity
|
|
||||||
)
|
|
||||||
if fallback_unique_id:
|
|
||||||
await self.async_set_unique_id(fallback_unique_id)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
|
|
||||||
# Auto-detect Jellyfin if not manually specified
|
# Auto-detect Jellyfin from the names setup already knows: the
|
||||||
|
# entry name the user typed, or the device name from discovery.
|
||||||
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
||||||
if not jellyfin:
|
if not jellyfin:
|
||||||
device_name = user_input.get("name", DEFAULT_NAME)
|
for name in (user_input.get("name", DEFAULT_NAME), self._discovered_name):
|
||||||
atv_state = (
|
|
||||||
self.hass.states.get(androidtv_entity)
|
|
||||||
if androidtv_entity
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
|
|
||||||
|
|
||||||
for name in [device_name, atv_name]:
|
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
found = _find_jellyfin_entity(self.hass, name)
|
found = _find_jellyfin_entity(self.hass, name)
|
||||||
@@ -298,7 +245,6 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
self._user_data = {
|
self._user_data = {
|
||||||
"name": user_input.get("name", DEFAULT_NAME),
|
"name": user_input.get("name", DEFAULT_NAME),
|
||||||
CONF_ANDROIDTV_ENTITY: androidtv_entity or "",
|
|
||||||
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
||||||
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
||||||
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
||||||
@@ -433,14 +379,16 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return await self.async_step_user()
|
return await self.async_step_user()
|
||||||
|
|
||||||
def _create_entry(self):
|
def _create_entry(self):
|
||||||
"""Create the config entry from stored user data."""
|
"""Create the config entry from stored user data.
|
||||||
|
|
||||||
|
Everything the integration needs lives in the OPTIONS (t41: one host
|
||||||
|
field among them); ``data`` stays empty because no value has to be
|
||||||
|
immutable.
|
||||||
|
"""
|
||||||
d = self._user_data
|
d = self._user_data
|
||||||
entry_data = {}
|
|
||||||
if d.get(CONF_ANDROIDTV_ENTITY):
|
|
||||||
entry_data[CONF_ANDROIDTV_ENTITY] = d[CONF_ANDROIDTV_ENTITY]
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=d["name"],
|
title=d["name"],
|
||||||
data=entry_data,
|
data={},
|
||||||
options={
|
options={
|
||||||
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
||||||
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
||||||
@@ -722,7 +670,6 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
# the entry already knows (and _create_entry is never reached here).
|
# the entry already knows (and _create_entry is never reached here).
|
||||||
self._user_data = {
|
self._user_data = {
|
||||||
"name": entry.title,
|
"name": entry.title,
|
||||||
CONF_ANDROIDTV_ENTITY: entry.data.get(CONF_ANDROIDTV_ENTITY, ""),
|
|
||||||
CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
|
CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
|
||||||
CONF_ADB_HOST: entry.options.get(CONF_ADB_HOST, ""),
|
CONF_ADB_HOST: entry.options.get(CONF_ADB_HOST, ""),
|
||||||
CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
@@ -774,9 +721,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return self.async_abort(reason="reauth_successful")
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
def _resolve_adb_host(self) -> str | None:
|
def _resolve_adb_host(self) -> str | None:
|
||||||
"""Resolve ADB host from explicit config or the Android TV entity."""
|
"""The address to use for ADB: the ADB field, else the protocol field."""
|
||||||
return self._user_data.get(CONF_ADB_HOST) or discover_adb_host(
|
return (
|
||||||
self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY)
|
self._user_data.get(CONF_ADB_HOST)
|
||||||
|
or self._user_data.get(CONF_PROTOCOL_HOST)
|
||||||
|
or None
|
||||||
)
|
)
|
||||||
|
|
||||||
def _show_adb_pair_form(self, errors=None):
|
def _show_adb_pair_form(self, errors=None):
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import os
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADB_HOST,
|
CONF_ADB_HOST,
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_PROTOCOL_HOST,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
@@ -42,31 +41,14 @@ def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]:
|
|||||||
return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
|
return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
|
||||||
|
|
||||||
|
|
||||||
def discover_adb_host(
|
|
||||||
hass: HomeAssistant, androidtv_entity_id: str | None
|
|
||||||
) -> str | None:
|
|
||||||
"""Best-effort host for an AndroidTV entity, from its owning config entry."""
|
|
||||||
if not androidtv_entity_id:
|
|
||||||
return None
|
|
||||||
registry = er.async_get(hass)
|
|
||||||
entity_entry = registry.async_get(androidtv_entity_id)
|
|
||||||
if not entity_entry or not entity_entry.config_entry_id:
|
|
||||||
return None
|
|
||||||
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
|
||||||
if config_entry:
|
|
||||||
return config_entry.data.get("host")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
|
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
|
||||||
"""Resolve host/port/key_path for the entry's ADB connection, or None.
|
"""Resolve host/port/key_path for the entry's ADB connection, or None.
|
||||||
|
|
||||||
Priority: explicit ADB host/port options > host from the AndroidTV
|
Priority: the entry's own address fields (t41: no other integration is
|
||||||
entity's own config entry.
|
consulted) — the ADB field first, then the protocol field, since both
|
||||||
|
describe the same Shield.
|
||||||
"""
|
"""
|
||||||
host = entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
host = entry.options.get(CONF_ADB_HOST) or entry.options.get(CONF_PROTOCOL_HOST)
|
||||||
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
|
||||||
)
|
|
||||||
if not host:
|
if not host:
|
||||||
return None
|
return None
|
||||||
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
|
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ from .const import (
|
|||||||
CHANNEL_DIGIT_DELAY,
|
CHANNEL_DIGIT_DELAY,
|
||||||
CONF_ADB_COMMAND_ENABLED,
|
CONF_ADB_COMMAND_ENABLED,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
|
||||||
CONF_APP_ICON_MAP,
|
CONF_APP_ICON_MAP,
|
||||||
CONF_APP_NAME_MAP,
|
CONF_APP_NAME_MAP,
|
||||||
CONF_CHANNEL_DIGIT_DELAY,
|
CONF_CHANNEL_DIGIT_DELAY,
|
||||||
@@ -121,8 +120,12 @@ async def _migrate_legacy_unique_id(hass, entry) -> None:
|
|||||||
legacy pattern no longer matches, so this never runs twice for an
|
legacy pattern no longer matches, so this never runs twice for an
|
||||||
entry. No behavior change: the entry's data/options are untouched.
|
entry. No behavior change: the entry's data/options are untouched.
|
||||||
"""
|
"""
|
||||||
legacy = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
# t41: the entity path is gone, so the SHAPE of the stale id is the marker.
|
||||||
if not legacy or entry.unique_id != legacy:
|
# Legacy entries (pre-t29 G2) carry the androidtv entity id as unique_id;
|
||||||
|
# every other shape (a MAC, the config-entry id, a yaml: seed id) is left
|
||||||
|
# exactly as it is.
|
||||||
|
legacy = entry.unique_id or ""
|
||||||
|
if not legacy.startswith("media_player."):
|
||||||
return
|
return
|
||||||
manager = getattr(entry, "runtime_data", None)
|
manager = getattr(entry, "runtime_data", None)
|
||||||
new_id = None
|
new_id = None
|
||||||
@@ -186,18 +189,16 @@ def resolve_app_name(
|
|||||||
package: str | None,
|
package: str | None,
|
||||||
app_name_map,
|
app_name_map,
|
||||||
app_label,
|
app_label,
|
||||||
androidtv_app_name: str | None,
|
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Resolve a package id to a friendly name (t40).
|
"""Resolve a package id to a friendly name (t40; entity tier removed t41).
|
||||||
|
|
||||||
The single chain behind ``media_player.app_name`` and the Current App
|
The single chain behind ``media_player.app_name`` and the Current App
|
||||||
sensor (which used to publish the raw package id):
|
sensor:
|
||||||
|
|
||||||
1. the user's explicit apps map (``CONF_APP_NAME_MAP``);
|
1. the user's explicit apps map (``CONF_APP_NAME_MAP``);
|
||||||
2. the cached device-manifest label (``manager.app_label`` — a cache miss
|
2. the cached device-manifest label (``manager.app_label`` — a cache miss
|
||||||
enqueues the on-demand fetch and falls through);
|
enqueues the on-demand fetch and falls through);
|
||||||
3. the androidtv entity tier (that entity's ``app_name`` attribute);
|
3. the raw package text — a bare or unknown package falls back to itself,
|
||||||
4. the raw package text — a bare or unknown package falls back to itself,
|
|
||||||
never to None.
|
never to None.
|
||||||
"""
|
"""
|
||||||
if package:
|
if package:
|
||||||
@@ -207,8 +208,6 @@ def resolve_app_name(
|
|||||||
label = app_label(package) if app_label is not None else None
|
label = app_label(package) if app_label is not None else None
|
||||||
if label:
|
if label:
|
||||||
return label
|
return label
|
||||||
if androidtv_app_name:
|
|
||||||
return androidtv_app_name
|
|
||||||
return package or None
|
return package or None
|
||||||
|
|
||||||
|
|
||||||
@@ -223,7 +222,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self._entry = entry
|
self._entry = entry
|
||||||
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
|
||||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
|
||||||
self._unsub_listeners: list = []
|
self._unsub_listeners: list = []
|
||||||
|
|
||||||
@@ -332,11 +330,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
self._on_manager_update
|
self._on_manager_update
|
||||||
)
|
)
|
||||||
|
|
||||||
entities = [self._androidtv_entity]
|
entities = [
|
||||||
if self._jellyfin_entity:
|
entity_id
|
||||||
entities.append(self._jellyfin_entity)
|
for entity_id in (self._jellyfin_entity, self._power_entity)
|
||||||
if self._power_entity:
|
if entity_id
|
||||||
entities.append(self._power_entity)
|
]
|
||||||
self._unsub_listeners.append(
|
self._unsub_listeners.append(
|
||||||
async_track_state_change_event(
|
async_track_state_change_event(
|
||||||
self.hass, entities, self._on_source_change
|
self.hass, entities, self._on_source_change
|
||||||
@@ -436,9 +434,18 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
self._manager.send_media_command(intent.adb_command)
|
self._manager.send_media_command(intent.adb_command)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._track_task(
|
# t41: neither of OUR transports served this command (both were
|
||||||
self._cmd(intent.command)
|
# down when it was sent), so there is nothing to re-send. Retrying
|
||||||
|
# by delegating to another integration is exactly what this change
|
||||||
|
# removes — surrender to the truth instead.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Intent %s had no serving transport to retry — falling back "
|
||||||
|
"to truth",
|
||||||
|
intent.command,
|
||||||
)
|
)
|
||||||
|
self._clear_intent()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
return
|
||||||
self._intent_timer_unsub = async_call_later(
|
self._intent_timer_unsub = async_call_later(
|
||||||
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
||||||
)
|
)
|
||||||
@@ -456,13 +463,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _on_source_change(self, event: Event) -> None:
|
def _on_source_change(self, event: Event) -> None:
|
||||||
"""React to state changes in androidtv or Jellyfin."""
|
"""React to state changes in Jellyfin or the power switch."""
|
||||||
entity_id = event.data.get("entity_id")
|
entity_id = event.data.get("entity_id")
|
||||||
new_state_obj = event.data.get("new_state")
|
new_state_obj = event.data.get("new_state")
|
||||||
|
|
||||||
# Power off clears any intent — device is off, intent is moot
|
# Power off clears any intent — device is off, intent is moot
|
||||||
if (
|
if (
|
||||||
entity_id in (self._androidtv_entity, self._power_entity)
|
entity_id == self._power_entity
|
||||||
and new_state_obj
|
and new_state_obj
|
||||||
and not self._is_power_on()
|
and not self._is_power_on()
|
||||||
and self._intent
|
and self._intent
|
||||||
@@ -640,16 +647,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def app_id(self):
|
def app_id(self):
|
||||||
"""Android TV entity first; standalone setups fall back to the
|
"""The raw foreground package from the manager's store (t41).
|
||||||
protocol current-app push (raw package — both stamp the store)."""
|
|
||||||
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
Both native transports stamp it: the protocol current-app push and the
|
||||||
if atv is not None:
|
ADB mResumedActivity parse. None until one of them has reported.
|
||||||
app_id = atv.attributes.get("app_id")
|
"""
|
||||||
if app_id:
|
return self._manager.current_app if self._manager is not None else None
|
||||||
return app_id
|
|
||||||
if self._manager is not None:
|
|
||||||
return self._manager.current_app
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _app_name_map(self) -> dict:
|
def _app_name_map(self) -> dict:
|
||||||
@@ -663,19 +666,16 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
1. CONF_APP_NAME_MAP (the user's explicit override)
|
1. CONF_APP_NAME_MAP (the user's explicit override)
|
||||||
2. device manifest label (`pm dump`, cached ≥24 h, resolved on
|
2. device manifest label (`pm dump`, cached ≥24 h, resolved on
|
||||||
demand — a miss enqueues the fetch and falls through)
|
demand — a miss enqueues the fetch and falls through)
|
||||||
3. androidtv entity tier (when configured)
|
3. raw package (protocol push / ADB foreground parse)
|
||||||
4. raw package (protocol push / ADB foreground parse)
|
|
||||||
"""
|
"""
|
||||||
# t40: one implementation, shared with the Current App sensor
|
# t40: one implementation, shared with the Current App sensor
|
||||||
# (``resolve_app_name``) — the two can no longer disagree about what
|
# (``resolve_app_name``) — the two can no longer disagree about what
|
||||||
# is on screen.
|
# is on screen.
|
||||||
package = self._manager.foreground_app if self._manager is not None else None
|
package = self._manager.foreground_app if self._manager is not None else None
|
||||||
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
|
||||||
return resolve_app_name(
|
return resolve_app_name(
|
||||||
package,
|
package,
|
||||||
self._app_name_map,
|
self._app_name_map,
|
||||||
self._manager.app_label if self._manager is not None else None,
|
self._manager.app_label if self._manager is not None else None,
|
||||||
atv.attributes.get("app_name") if atv is not None else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -922,17 +922,29 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
attrs["_intent"] = self._intent.to_debug_dict()
|
attrs["_intent"] = self._intent.to_debug_dict()
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
# ── Commands — ALL go to androidtv_remote ──
|
# ── Commands — our own transports only (t41: the external entity is gone) ──
|
||||||
|
|
||||||
async def _cmd(self, service: str, **extra) -> None:
|
async def _power_key(self, *, send_when_on: bool) -> None:
|
||||||
data = {"entity_id": self._androidtv_entity, **extra}
|
"""Send the protocol POWER key when the known state disagrees (t41).
|
||||||
await self.hass.services.async_call("media_player", service, data)
|
|
||||||
|
Both native transports are ours now, so this no longer delegates to an
|
||||||
|
androidtv_remote entity: the protocol POWER key is the path we own. An
|
||||||
|
unknown state sends nothing (no superfluous toggle — the same rule the
|
||||||
|
remote entity applies), and without the protocol transport there is
|
||||||
|
nothing to transport the key over.
|
||||||
|
"""
|
||||||
|
manager = self._manager
|
||||||
|
if manager is None or manager.is_on is not send_when_on:
|
||||||
|
return
|
||||||
|
await manager.send_protocol_key(PROTOCOL_REMOTE_KEYS["power"])
|
||||||
|
|
||||||
async def async_turn_on(self):
|
async def async_turn_on(self):
|
||||||
await self._cmd("turn_on")
|
"""Wake the Shield (only when it reports OFF)."""
|
||||||
|
await self._power_key(send_when_on=False)
|
||||||
|
|
||||||
async def async_turn_off(self):
|
async def async_turn_off(self):
|
||||||
await self._cmd("turn_off")
|
"""Sleep the Shield (only when it reports ON)."""
|
||||||
|
await self._power_key(send_when_on=True)
|
||||||
|
|
||||||
async def async_volume_up(self):
|
async def async_volume_up(self):
|
||||||
await self._remote_key_cmd("volume_up", "volume_up")
|
await self._remote_key_cmd("volume_up", "volume_up")
|
||||||
@@ -945,7 +957,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
await self._remote_key_cmd("volume_mute", "mute")
|
await self._remote_key_cmd("volume_mute", "mute")
|
||||||
|
|
||||||
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
||||||
"""Simple key command without intent bookkeeping: protocol → ADB → legacy."""
|
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
|
||||||
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
|
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
|
||||||
if (
|
if (
|
||||||
protocol_key
|
protocol_key
|
||||||
@@ -955,16 +967,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
return
|
return
|
||||||
if self._manager is not None and await self._manager.send_media_command(adb_key):
|
if self._manager is not None and await self._manager.send_media_command(adb_key):
|
||||||
return
|
return
|
||||||
# Transitional: external androidtv_remote entity (removed in Phase 4)
|
|
||||||
await self._cmd(remote_key)
|
|
||||||
|
|
||||||
async def _media_cmd(
|
async def _media_cmd(
|
||||||
self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None
|
self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a media command over the fastest available command path.
|
"""Send a media command over the fastest available command path.
|
||||||
|
|
||||||
Order (Phase 2): protocol key push → ADB keyevent → the external
|
Order: protocol key push → ADB keyevent (t41: our own transports only —
|
||||||
androidtv_remote entity (transitional until Phase 4 removes it).
|
the transitional external-entity tier is gone). With neither transport
|
||||||
|
up the command is a no-op, never a call into another integration.
|
||||||
"""
|
"""
|
||||||
if intent_state:
|
if intent_state:
|
||||||
self._set_intent(ha_service, intent_state)
|
self._set_intent(ha_service, intent_state)
|
||||||
@@ -997,9 +1008,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
self._intent.adb_command = adb_command
|
self._intent.adb_command = adb_command
|
||||||
return
|
return
|
||||||
|
|
||||||
# 3. Transitional: the external androidtv_remote entity (removed in Phase 4)
|
|
||||||
await self._cmd(ha_service)
|
|
||||||
|
|
||||||
async def async_media_play(self):
|
async def async_media_play(self):
|
||||||
await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING)
|
await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING)
|
||||||
|
|
||||||
@@ -1134,16 +1142,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False)
|
send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False)
|
||||||
try:
|
try:
|
||||||
for digit in digits:
|
for digit in digits:
|
||||||
# 1. Protocol digit key; 2. ADB keyevent 7..16; 3. legacy entity
|
# 1. Protocol digit key; 2. ADB keyevent 7..16 (t41: our own
|
||||||
|
# transports only — an unserved digit is dropped, never handed
|
||||||
|
# to another integration)
|
||||||
served = False
|
served = False
|
||||||
if self._manager is not None:
|
if self._manager is not None:
|
||||||
served = await self._manager.send_protocol_key(f"KEYCODE_{digit}")
|
served = await self._manager.send_protocol_key(f"KEYCODE_{digit}")
|
||||||
if not served:
|
if not served:
|
||||||
served = await self._manager.send_digit_key(digit)
|
served = await self._manager.send_digit_key(digit)
|
||||||
if not served:
|
if not served:
|
||||||
await self._cmd(
|
_LOGGER.warning(
|
||||||
"play_media",
|
"channel digit %s not sent — no available transport", digit
|
||||||
media={"media_content_id": digit, "media_content_type": "channel"},
|
|
||||||
)
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from homeassistant.helpers.event import async_track_state_change_event
|
|||||||
|
|
||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ANDROIDTV_ENTITY,
|
|
||||||
CONF_APP_NAME_MAP,
|
CONF_APP_NAME_MAP,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -67,9 +66,9 @@ class NvidiaShieldSensorBase(SensorEntity):
|
|||||||
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||||
"""Current running app on the Shield.
|
"""Current running app on the Shield.
|
||||||
|
|
||||||
Primary source: the androidtv entity's own state. When that entity is
|
Source: the ShieldConnectionManager's StateStore — the protocol current-app
|
||||||
unavailable, falls back to the ShieldConnectionManager's StateStore
|
push, or the most recent visible ADB recent-task app when that has not
|
||||||
(most recent visible ADB recent-task app).
|
reported (t41: our own transports only).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_attr_translation_key = "current_app"
|
_attr_translation_key = "current_app"
|
||||||
@@ -77,7 +76,6 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
super().__init__(hass, entry)
|
super().__init__(hass, entry)
|
||||||
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
|
||||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
||||||
|
|
||||||
def _manager(self) -> ShieldConnectionManager | None:
|
def _manager(self) -> ShieldConnectionManager | None:
|
||||||
@@ -85,49 +83,30 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
return manager if isinstance(manager, ShieldConnectionManager) else None
|
return manager if isinstance(manager, ShieldConnectionManager) else None
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
self._unsub_listeners.append(
|
|
||||||
async_track_state_change_event(
|
|
||||||
self.hass, [self._androidtv_entity], self._handle_state_change
|
|
||||||
)
|
|
||||||
)
|
|
||||||
manager = self._manager()
|
manager = self._manager()
|
||||||
if manager is not None:
|
if manager is not None:
|
||||||
# Refresh when the connection manager updates its store.
|
# Refresh when the connection manager updates its store.
|
||||||
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
||||||
|
|
||||||
@callback
|
|
||||||
def _handle_state_change(self, event: Event) -> None:
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_store_update(self) -> None:
|
def _handle_store_update(self) -> None:
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
def _androidtv_state(self):
|
|
||||||
"""The configured androidtv entity's state object, or None."""
|
|
||||||
if not self._androidtv_entity:
|
|
||||||
return None
|
|
||||||
return self.hass.states.get(self._androidtv_entity)
|
|
||||||
|
|
||||||
def _package(self) -> str | None:
|
def _package(self) -> str | None:
|
||||||
"""The raw foreground package (t40): entity tier, then the store.
|
"""The raw foreground package from the manager's store (t41).
|
||||||
|
|
||||||
androidtv entity unavailable — StateStore priority chain (P3-3c):
|
StateStore priority chain (P3-3c): protocol current_app push → ADB
|
||||||
protocol current_app push → ADB mResumedActivity → ADB recents.
|
mResumedActivity → ADB recents.
|
||||||
"""
|
"""
|
||||||
state = self._androidtv_state()
|
|
||||||
if state is not None:
|
|
||||||
package = state.attributes.get("app_id")
|
|
||||||
if package:
|
|
||||||
return package
|
|
||||||
manager = self._manager()
|
manager = self._manager()
|
||||||
if manager is not None:
|
if manager is None:
|
||||||
foreground = manager.foreground_app
|
return None
|
||||||
if foreground:
|
foreground = manager.foreground_app
|
||||||
return foreground
|
if foreground:
|
||||||
running = manager.running_apps
|
return foreground
|
||||||
if running:
|
running = manager.running_apps
|
||||||
return running[0].get("package")
|
if running:
|
||||||
|
return running[0].get("package")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -135,17 +114,15 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
"""The resolved friendly name (t40), not the raw package id.
|
"""The resolved friendly name (t40), not the raw package id.
|
||||||
|
|
||||||
Exactly the ``media_player.app_name`` chain via the shared
|
Exactly the ``media_player.app_name`` chain via the shared
|
||||||
``resolve_app_name``: apps map → cached manifest label → androidtv
|
``resolve_app_name``: apps map → cached manifest label → the package
|
||||||
entity tier → the package text itself. A bare or unknown package shows
|
text itself. A bare or unknown package shows its package text rather
|
||||||
its package text rather than None, like the media player's app_name.
|
than None, like the media player's app_name.
|
||||||
"""
|
"""
|
||||||
manager = self._manager()
|
manager = self._manager()
|
||||||
state = self._androidtv_state()
|
|
||||||
return resolve_app_name(
|
return resolve_app_name(
|
||||||
self._package(),
|
self._package(),
|
||||||
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
|
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
|
||||||
manager.app_label if manager is not None else None,
|
manager.app_label if manager is not None else None,
|
||||||
state.attributes.get("app_name") if state is not None else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
|||||||
player._entry = entry
|
player._entry = entry
|
||||||
player._manager = manager
|
player._manager = manager
|
||||||
player._intent = None
|
player._intent = None
|
||||||
|
player._intent_timer_unsub = None # mirrors __init__ (intent self-heal)
|
||||||
player._last_channel = None
|
player._last_channel = None
|
||||||
player.hass = entity_stublib.FakeHass()
|
player.hass = entity_stublib.FakeHass()
|
||||||
player._entity_tasks = set()
|
player._entity_tasks = set()
|
||||||
@@ -123,13 +124,16 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
|||||||
player.async_write_ha_state = lambda: None
|
player.async_write_ha_state = lambda: None
|
||||||
player._read = lambda eid: None
|
player._read = lambda eid: None
|
||||||
|
|
||||||
|
# t41: nothing may leave this integration, so an HA service call is now the
|
||||||
|
# failure signal rather than a supported tier. `served` stays empty unless a
|
||||||
|
# regression reintroduces the external-entity delegation.
|
||||||
served = []
|
served = []
|
||||||
|
|
||||||
async def _cmd(service, **kwargs):
|
class _RecordingServices:
|
||||||
served.append(("legacy", service))
|
async def async_call(self, domain, service, data=None, **kwargs):
|
||||||
return None
|
served.append((domain, service))
|
||||||
|
|
||||||
player._cmd = _cmd
|
player.hass.services = _RecordingServices()
|
||||||
|
|
||||||
captured_intents = []
|
captured_intents = []
|
||||||
|
|
||||||
@@ -248,7 +252,8 @@ class TestChannelDigits:
|
|||||||
|
|
||||||
await player._channel_entry_sequence(["7"])
|
await player._channel_entry_sequence(["7"])
|
||||||
|
|
||||||
assert served == [("legacy", "play_media")] # transitional path used
|
# t41: both transports down is a no-op — no other integration is called
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
|
||||||
class TestAppAndUrls:
|
class TestAppAndUrls:
|
||||||
@@ -346,38 +351,67 @@ class TestRemoteEntity:
|
|||||||
assert protocol.key_calls == []
|
assert protocol.key_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntentRetryWithoutATransport:
|
||||||
|
"""t41: an intent no transport served surrenders instead of delegating."""
|
||||||
|
|
||||||
|
def _intent(self, **overrides):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
intent = SimpleNamespace(
|
||||||
|
target_state="playing",
|
||||||
|
command="media_play",
|
||||||
|
created_at=0.0,
|
||||||
|
prior_state=None,
|
||||||
|
retry_count=0,
|
||||||
|
via_adb=False,
|
||||||
|
via_protocol=False,
|
||||||
|
adb_command=None,
|
||||||
|
protocol_key=None,
|
||||||
|
)
|
||||||
|
for key, value in overrides.items():
|
||||||
|
setattr(intent, key, value)
|
||||||
|
return intent
|
||||||
|
|
||||||
|
async def test_unserved_intent_surrenders_without_a_service_call(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
player.async_write_ha_state = lambda: None
|
||||||
|
player._intent = self._intent()
|
||||||
|
|
||||||
|
player._on_intent_timeout(0.0) # async_call_later passes the timestamp
|
||||||
|
|
||||||
|
assert player._intent is None # surrendered to the truth
|
||||||
|
assert served == [] # and nothing left this integration
|
||||||
|
|
||||||
|
async def test_protocol_served_intent_still_retries_over_the_protocol(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
player.async_write_ha_state = lambda: None
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
player._intent = self._intent(
|
||||||
|
via_protocol=True, protocol_key="KEYCODE_MEDIA_PLAY"
|
||||||
|
)
|
||||||
|
|
||||||
|
player._on_intent_timeout(0.0) # async_call_later passes the timestamp
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert protocol.key_calls == [("KEYCODE_MEDIA_PLAY", "SHORT")]
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
|
||||||
class TestAppFallback:
|
class TestAppFallback:
|
||||||
async def test_app_id_prefers_the_androidtv_entity(self, modules):
|
"""t41: the app identity comes from OUR transports' store only."""
|
||||||
|
|
||||||
|
async def test_app_id_comes_from_the_manager_store(self, modules):
|
||||||
player, _s, _i = make_player(modules)
|
player, _s, _i = make_player(modules)
|
||||||
player._androidtv_entity = "media_player.atv"
|
player._manager._on_current_app("com.plex.hdx")
|
||||||
from types import SimpleNamespace as FakeState
|
|
||||||
|
|
||||||
state = FakeState(attributes={"app_id": "com.plex.hdx", "app_name": "Plex"})
|
|
||||||
states = FakeState(states=FakeState(
|
|
||||||
get=lambda eid: state if eid == "media_player.atv" else None
|
|
||||||
))
|
|
||||||
player.hass = states
|
|
||||||
player._read = lambda eid: state if eid == "media_player.atv" else None
|
|
||||||
|
|
||||||
assert player.app_id == "com.plex.hdx"
|
|
||||||
assert player.app_name == "Plex"
|
|
||||||
|
|
||||||
async def test_app_falls_back_to_protocol_current_app(self, modules):
|
|
||||||
player, _s, _i = make_player(modules)
|
|
||||||
player._androidtv_entity = "" # standalone setup (P3-1)
|
|
||||||
player._manager._on_current_app("com.netflix.ninja")
|
|
||||||
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
|
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
|
||||||
|
|
||||||
assert player.app_id == "com.netflix.ninja"
|
assert player.app_id == "com.plex.hdx"
|
||||||
assert player.app_name == "com.netflix.ninja" # package is truth; map later
|
assert player.app_name == "com.plex.hdx" # package is truth; map later
|
||||||
|
|
||||||
async def test_app_unavailable_entity_falls_back(self, modules):
|
async def test_app_follows_the_latest_protocol_push(self, modules):
|
||||||
player, _s, _i = make_player(modules)
|
player, _s, _i = make_player(modules)
|
||||||
player._androidtv_entity = "media_player.atv"
|
player._manager._on_current_app("com.netflix.ninja")
|
||||||
from types import SimpleNamespace as FakeState
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
player.hass = FakeState(states=FakeState(get=lambda eid: None)) # entity gone
|
|
||||||
player._read = lambda eid: None
|
|
||||||
player._manager._on_current_app("com.google.android.youtube.tv")
|
player._manager._on_current_app("com.google.android.youtube.tv")
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
@@ -393,7 +427,6 @@ def _ladder_player(modules, foreground=None, audio=None):
|
|||||||
manager.store.set("foreground", foreground)
|
manager.store.set("foreground", foreground)
|
||||||
if audio:
|
if audio:
|
||||||
manager.store.set("audio_playback", audio)
|
manager.store.set("audio_playback", audio)
|
||||||
player._androidtv_entity = ""
|
|
||||||
player._read = lambda eid: None
|
player._read = lambda eid: None
|
||||||
return player, manager
|
return player, manager
|
||||||
|
|
||||||
@@ -411,7 +444,6 @@ class TestForegroundLadderTier:
|
|||||||
manager.store.set("foreground", foreground)
|
manager.store.set("foreground", foreground)
|
||||||
if audio:
|
if audio:
|
||||||
manager.store.set("audio_playback", audio)
|
manager.store.set("audio_playback", audio)
|
||||||
player._androidtv_entity = ""
|
|
||||||
player._read = lambda eid: None
|
player._read = lambda eid: None
|
||||||
return player, manager
|
return player, manager
|
||||||
|
|
||||||
@@ -1187,12 +1219,16 @@ def make_manager_with(*, poller=None, protocol=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_app_sensor(modules, manager, entry, *, atv_state=None):
|
def make_app_sensor(modules, manager, entry):
|
||||||
"""A Current App sensor over the given manager/entry (t40)."""
|
"""A Current App sensor over the given manager/entry (t40).
|
||||||
|
|
||||||
|
t41: the sensor reads the manager's store and nothing else, so the harness
|
||||||
|
hands it a hass whose state machine is empty — a value here can only come
|
||||||
|
from our own transports.
|
||||||
|
"""
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
states = {"media_player.atv": atv_state} if atv_state is not None else {}
|
hass = SimpleNamespace(states=SimpleNamespace(get=lambda entity_id: None))
|
||||||
hass = SimpleNamespace(states=SimpleNamespace(get=states.get))
|
|
||||||
return modules["sensor"].NvidiaShieldCurrentAppSensor(hass, entry)
|
return modules["sensor"].NvidiaShieldCurrentAppSensor(hass, entry)
|
||||||
|
|
||||||
|
|
||||||
@@ -1232,20 +1268,6 @@ class TestCurrentAppSensorFriendlyName:
|
|||||||
assert sensor.native_value == "Netflix"
|
assert sensor.native_value == "Netflix"
|
||||||
assert sensor.extra_state_attributes["app_id"] == "com.netflix.ninja"
|
assert sensor.extra_state_attributes["app_id"] == "com.netflix.ninja"
|
||||||
|
|
||||||
def test_androidtv_entity_tier_serves_when_no_map_or_label(self, modules):
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
player, _served, _intents = make_player(modules)
|
|
||||||
player._entry.data["androidtv_entity"] = "media_player.atv"
|
|
||||||
atv_state = SimpleNamespace(
|
|
||||||
attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"}
|
|
||||||
)
|
|
||||||
sensor = make_app_sensor(modules, player._manager, player._entry, atv_state=atv_state)
|
|
||||||
|
|
||||||
player._manager._on_current_app("ar.tvplayer.tv")
|
|
||||||
|
|
||||||
assert sensor.native_value == "ATV Tivi"
|
|
||||||
|
|
||||||
def test_recent_apps_fallback_resolves_through_the_label_tier(self, modules):
|
def test_recent_apps_fallback_resolves_through_the_label_tier(self, modules):
|
||||||
player, _served, _intents = make_player(modules)
|
player, _served, _intents = make_player(modules)
|
||||||
manager = player._manager
|
manager = player._manager
|
||||||
@@ -1271,7 +1293,6 @@ class TestCurrentAppSensorFriendlyName:
|
|||||||
"""One chain, two entities — they cannot disagree about the app name."""
|
"""One chain, two entities — they cannot disagree about the app name."""
|
||||||
player, _served, _intents = make_player(modules)
|
player, _served, _intents = make_player(modules)
|
||||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||||
player._androidtv_entity = "" # standalone, like the unit harness
|
|
||||||
sensor = self._sensor(modules, player)
|
sensor = self._sensor(modules, player)
|
||||||
|
|
||||||
player._manager._on_current_app("ar.tvplayer.tv")
|
player._manager._on_current_app("ar.tvplayer.tv")
|
||||||
|
|||||||
@@ -578,7 +578,7 @@ class TestDiscoveryPrefill:
|
|||||||
await flow.async_step_zeroconf(_discovery())
|
await flow.async_step_zeroconf(_discovery())
|
||||||
|
|
||||||
result = await flow.async_step_user(
|
result = await flow.async_step_user(
|
||||||
_user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: False})
|
_user_input(**{CONF_ADB_ENABLED: False})
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["errors"] == {"base": "no_state_source"}
|
assert result["errors"] == {"base": "no_state_source"}
|
||||||
@@ -630,7 +630,6 @@ class TestNoDeadEndPairing:
|
|||||||
form = await flow.async_step_user(
|
form = await flow.async_step_user(
|
||||||
_user_input(
|
_user_input(
|
||||||
**{
|
**{
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
CONF_PROTOCOL_HOST: DISCOVERED_HOST,
|
CONF_PROTOCOL_HOST: DISCOVERED_HOST,
|
||||||
}
|
}
|
||||||
@@ -662,7 +661,6 @@ class TestNoDeadEndPairing:
|
|||||||
form = await flow.async_step_user(
|
form = await flow.async_step_user(
|
||||||
_user_input(
|
_user_input(
|
||||||
**{
|
**{
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_ADB_ENABLED: True,
|
CONF_ADB_ENABLED: True,
|
||||||
CONF_ADB_HOST: DISCOVERED_HOST,
|
CONF_ADB_HOST: DISCOVERED_HOST,
|
||||||
}
|
}
|
||||||
@@ -695,7 +693,6 @@ class TestNoDeadEndPairing:
|
|||||||
form = await flow.async_step_user(
|
form = await flow.async_step_user(
|
||||||
_user_input(
|
_user_input(
|
||||||
**{
|
**{
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_ADB_ENABLED: True,
|
CONF_ADB_ENABLED: True,
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
CONF_ADB_HOST: DISCOVERED_HOST,
|
CONF_ADB_HOST: DISCOVERED_HOST,
|
||||||
@@ -718,7 +715,6 @@ class TestNoDeadEndPairing:
|
|||||||
await flow.async_step_user(
|
await flow.async_step_user(
|
||||||
_user_input(
|
_user_input(
|
||||||
**{
|
**{
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_ADB_ENABLED: True,
|
CONF_ADB_ENABLED: True,
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
CONF_ADB_HOST: DISCOVERED_HOST,
|
CONF_ADB_HOST: DISCOVERED_HOST,
|
||||||
@@ -742,7 +738,6 @@ class TestNoDeadEndPairing:
|
|||||||
form = await flow.async_step_user(
|
form = await flow.async_step_user(
|
||||||
_user_input(
|
_user_input(
|
||||||
**{
|
**{
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
CONF_PROTOCOL_HOST: DISCOVERED_HOST,
|
CONF_PROTOCOL_HOST: DISCOVERED_HOST,
|
||||||
}
|
}
|
||||||
@@ -767,7 +762,6 @@ class TestNoDeadEndPairing:
|
|||||||
form = await flow.async_step_user(
|
form = await flow.async_step_user(
|
||||||
_user_input(
|
_user_input(
|
||||||
**{
|
**{
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_ADB_ENABLED: True,
|
CONF_ADB_ENABLED: True,
|
||||||
CONF_ADB_HOST: DISCOVERED_HOST,
|
CONF_ADB_HOST: DISCOVERED_HOST,
|
||||||
}
|
}
|
||||||
@@ -787,7 +781,7 @@ class TestNoDeadEndPairing:
|
|||||||
test_connect = _patch_adb(monkeypatch, module)
|
test_connect = _patch_adb(monkeypatch, module)
|
||||||
flow, _hass = _new_flow(module, tmp_path)
|
flow, _hass = _new_flow(module, tmp_path)
|
||||||
await flow.async_step_user(
|
await flow.async_step_user(
|
||||||
_user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: True})
|
_user_input(**{CONF_ADB_ENABLED: True})
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await flow.async_step_adb_pair(
|
result = await flow.async_step_adb_pair(
|
||||||
@@ -806,7 +800,6 @@ class TestNoDeadEndPairing:
|
|||||||
await flow.async_step_user(
|
await flow.async_step_user(
|
||||||
_user_input(
|
_user_input(
|
||||||
**{
|
**{
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
CONF_PROTOCOL_HOST: DISCOVERED_HOST,
|
CONF_PROTOCOL_HOST: DISCOVERED_HOST,
|
||||||
}
|
}
|
||||||
@@ -835,7 +828,7 @@ class TestNoDeadEndPairing:
|
|||||||
_patch_adb(monkeypatch, module, connected=False)
|
_patch_adb(monkeypatch, module, connected=False)
|
||||||
flow, _hass = _new_flow(module, tmp_path)
|
flow, _hass = _new_flow(module, tmp_path)
|
||||||
await flow.async_step_user(
|
await flow.async_step_user(
|
||||||
_user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: True})
|
_user_input(**{CONF_ADB_ENABLED: True})
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await flow.async_step_adb_pair({CONF_ADB_PORT: 5555})
|
result = await flow.async_step_adb_pair({CONF_ADB_PORT: 5555})
|
||||||
@@ -850,7 +843,6 @@ class TestNoDeadEndPairing:
|
|||||||
await flow.async_step_user(
|
await flow.async_step_user(
|
||||||
_user_input(
|
_user_input(
|
||||||
**{
|
**{
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_ADB_ENABLED: True,
|
CONF_ADB_ENABLED: True,
|
||||||
CONF_ADB_HOST: DISCOVERED_HOST,
|
CONF_ADB_HOST: DISCOVERED_HOST,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,12 +289,21 @@ def _entry_module():
|
|||||||
return sys.modules["nsh.entry"]
|
return sys.modules["nsh.entry"]
|
||||||
|
|
||||||
|
|
||||||
|
def _record(sink):
|
||||||
|
"""An async_set_unique_id stand-in that records what the flow tried."""
|
||||||
|
|
||||||
|
async def _set(unique_id):
|
||||||
|
sink.append(unique_id)
|
||||||
|
|
||||||
|
return _set
|
||||||
|
|
||||||
|
|
||||||
def _user_input(**overrides) -> dict:
|
def _user_input(**overrides) -> dict:
|
||||||
base = {
|
base = {
|
||||||
"name": "mdarcy",
|
"name": "mdarcy",
|
||||||
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
|
||||||
CONF_PROTOCOL_ENABLED: False,
|
CONF_PROTOCOL_ENABLED: False,
|
||||||
CONF_PROTOCOL_HOST: "",
|
CONF_PROTOCOL_HOST: "",
|
||||||
|
CONF_ADB_ENABLED: False,
|
||||||
}
|
}
|
||||||
base.update(overrides)
|
base.update(overrides)
|
||||||
return base
|
return base
|
||||||
@@ -306,21 +315,25 @@ class TestStandaloneValidation:
|
|||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
result = await flow.async_step_user(_user_input(**{CONF_ANDROIDTV_ENTITY: ""}))
|
result = await flow.async_step_user(_user_input())
|
||||||
|
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
assert result["step_id"] == "user"
|
assert result["step_id"] == "user"
|
||||||
assert result["errors"] == {"base": "no_state_source"}
|
assert result["errors"] == {"base": "no_state_source"}
|
||||||
|
|
||||||
async def test_androidtv_entity_alone_is_a_valid_source(self, tmp_path):
|
async def test_the_androidtv_field_is_gone_from_the_form(self, tmp_path):
|
||||||
|
"""t41: we own the stack — no other integration is a state source."""
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
result = await flow.async_step_user(_user_input())
|
result = await flow.async_step_user(None)
|
||||||
|
|
||||||
assert result["type"] == "create_entry"
|
fields = vol_mirror.declared_fields(result["data_schema"])
|
||||||
assert result["data"] == {CONF_ANDROIDTV_ENTITY: "media_player.shield"}
|
assert CONF_ANDROIDTV_ENTITY not in fields
|
||||||
|
# the two native transports are what is left to choose from
|
||||||
|
assert CONF_PROTOCOL_ENABLED in fields
|
||||||
|
assert CONF_ADB_ENABLED in fields
|
||||||
|
|
||||||
async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch):
|
async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
@@ -345,20 +358,19 @@ class TestStandaloneValidation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await flow.async_step_user(
|
result = await flow.async_step_user(
|
||||||
_user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True})
|
_user_input(**{CONF_PROTOCOL_ENABLED: True})
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
assert result["step_id"] == "protocol_pair" # routed into pairing, no error
|
assert result["step_id"] == "protocol_pair" # routed into pairing, no error
|
||||||
|
|
||||||
async def test_standalone_entry_data_omits_the_field(self, tmp_path, monkeypatch):
|
async def test_entry_data_is_empty_so_options_own_the_config(self, tmp_path, monkeypatch):
|
||||||
"""No androidtv entity → the key is absent from entry data (not "")."""
|
"""t41: every value lives in options; `data` carries no leftovers."""
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
flow._user_data = {
|
flow._user_data = {
|
||||||
"name": "mdarcy",
|
"name": "mdarcy",
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
CONF_PROTOCOL_HOST: "shield.local",
|
CONF_PROTOCOL_HOST: "shield.local",
|
||||||
}
|
}
|
||||||
@@ -367,7 +379,7 @@ class TestStandaloneValidation:
|
|||||||
result = flow._create_entry()
|
result = flow._create_entry()
|
||||||
|
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
assert CONF_ANDROIDTV_ENTITY not in result["data"]
|
assert result["data"] == {}
|
||||||
|
|
||||||
|
|
||||||
class TestAdbOnlyStandalone:
|
class TestAdbOnlyStandalone:
|
||||||
@@ -380,7 +392,6 @@ class TestAdbOnlyStandalone:
|
|||||||
result = await flow.async_step_user(
|
result = await flow.async_step_user(
|
||||||
_user_input(
|
_user_input(
|
||||||
**{
|
**{
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_PROTOCOL_ENABLED: False,
|
CONF_PROTOCOL_ENABLED: False,
|
||||||
"adb_enabled": True,
|
"adb_enabled": True,
|
||||||
}
|
}
|
||||||
@@ -398,7 +409,7 @@ class TestAdbOnlyStandalone:
|
|||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
result = await flow.async_step_user(
|
result = await flow.async_step_user(
|
||||||
_user_input(**{CONF_ANDROIDTV_ENTITY: "", "adb_enabled": False})
|
_user_input(**{"adb_enabled": False})
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["errors"] == {"base": "no_state_source"}
|
assert result["errors"] == {"base": "no_state_source"}
|
||||||
@@ -567,69 +578,33 @@ class TestChannelEntryOptions:
|
|||||||
assert result["errors"] == {"base": "invalid_digit_delay"}
|
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||||
|
|
||||||
|
|
||||||
class TestUniqueIdSourceG2:
|
class TestUniqueIdSource:
|
||||||
"""t29 G2: no unique_id equals the androidtv entity_id anymore."""
|
"""t41: no unique_id is derived from another integration, at any stage.
|
||||||
|
|
||||||
def test_entity_unique_id_fallback_is_gone(self):
|
t29 G2 replaced the renameable entity_id with a device-MAC derive; t41
|
||||||
from pathlib import Path
|
removes the entity path entirely, so the ONLY identity source left is the
|
||||||
|
protocol MAC learned during pairing (``async_set_unique_id(mac)``).
|
||||||
|
"""
|
||||||
|
|
||||||
base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield"
|
def test_no_entity_derived_unique_id_remains(self):
|
||||||
flow_src = (base / "config_flow.py").read_text()
|
flow_src = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
|
||||||
# the historic renameable fallback is gone from the user step
|
|
||||||
|
assert "_unique_id_from_atv_device" not in flow_src
|
||||||
assert "async_set_unique_id(androidtv_entity)" not in flow_src
|
assert "async_set_unique_id(androidtv_entity)" not in flow_src
|
||||||
# the documented replacement: a device-MAC derive, else no unique_id
|
# no registry lookups for another integration's devices either
|
||||||
assert "_unique_id_from_atv_device" in flow_src
|
assert "device_registry" not in flow_src
|
||||||
|
|
||||||
def test_entity_rename_never_moves_the_unique_id(self):
|
|
||||||
"""The MAC-derived id is rename-proof; a MAC-less entity has none."""
|
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_user_step_sets_no_unique_id(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
# a registered atv entity whose device carries a MAC
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
entity = SimpleNamespace(domain="media_player", device_id="dev1", platform="lgtv")
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
device = SimpleNamespace(
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
connections=[("mac", None), ("mac", "aa:bb:cc:dd:ee:ff")]
|
recorded = []
|
||||||
)
|
flow.async_set_unique_id = _record(recorded)
|
||||||
er_stub = module.er
|
|
||||||
er_stub.async_get = lambda hass: SimpleNamespace(
|
|
||||||
entities={"media_player.shield": entity}
|
|
||||||
)
|
|
||||||
class _DevRegistry:
|
|
||||||
def __init__(self, by_id):
|
|
||||||
self._by_id = by_id
|
|
||||||
|
|
||||||
def async_get(self, device_id):
|
await flow.async_step_user(_user_input(**{CONF_PROTOCOL_ENABLED: True}))
|
||||||
return self._by_id.get(device_id)
|
|
||||||
|
|
||||||
module.dr.async_get = lambda hass: _DevRegistry({"dev1": device})
|
assert recorded == []
|
||||||
|
|
||||||
# the resolved unique_id is the MAC — not the entity_id
|
|
||||||
uid = module._unique_id_from_atv_device(
|
|
||||||
FakeHass("/tmp"), "media_player.shield"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert uid == "aa:bb:cc:dd:ee:ff"
|
|
||||||
|
|
||||||
# renaming the entity does not affect the resolved value
|
|
||||||
er_stub.async_get = lambda hass: SimpleNamespace(
|
|
||||||
entities={"media_player.renamed": entity}
|
|
||||||
)
|
|
||||||
uid_renamed = module._unique_id_from_atv_device(
|
|
||||||
FakeHass("/tmp"), "media_player.renamed"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert uid_renamed == "aa:bb:cc:dd:ee:ff" # stable across renames
|
|
||||||
|
|
||||||
def test_macless_entity_yields_no_unique_id(self):
|
|
||||||
|
|
||||||
|
|
||||||
module = _form_module()
|
|
||||||
er_stub = module.er
|
|
||||||
er_stub.async_get = lambda hass: SimpleNamespace(entities={}) # unregistered
|
|
||||||
assert (
|
|
||||||
module._unique_id_from_atv_device(FakeHass("/tmp"), "media_player.x")
|
|
||||||
is None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestListenerComboGone:
|
class TestListenerComboGone:
|
||||||
@@ -1220,7 +1195,6 @@ class TestAppNameMapResolution:
|
|||||||
modules = entity_stublib.load_entity_modules()
|
modules = entity_stublib.load_entity_modules()
|
||||||
player, _s, _i = _cmd_player(modules)
|
player, _s, _i = _cmd_player(modules)
|
||||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||||
player._androidtv_entity = "" # standalone
|
|
||||||
|
|
||||||
manager = player._manager
|
manager = player._manager
|
||||||
manager._on_current_app("ar.tvplayer.tv") # protocol push
|
manager._on_current_app("ar.tvplayer.tv") # protocol push
|
||||||
@@ -1235,7 +1209,6 @@ class TestAppNameMapResolution:
|
|||||||
modules = entity_stublib.load_entity_modules()
|
modules = entity_stublib.load_entity_modules()
|
||||||
player, _s, _i = _cmd_player(modules)
|
player, _s, _i = _cmd_player(modules)
|
||||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||||
player._androidtv_entity = ""
|
|
||||||
|
|
||||||
manager = player._manager
|
manager = player._manager
|
||||||
manager._on_current_app("com.google.android.youtube.tv")
|
manager._on_current_app("com.google.android.youtube.tv")
|
||||||
@@ -1243,23 +1216,17 @@ class TestAppNameMapResolution:
|
|||||||
|
|
||||||
assert player.app_name == "com.google.android.youtube.tv"
|
assert player.app_name == "com.google.android.youtube.tv"
|
||||||
|
|
||||||
async def test_entity_tier_serves_when_no_higher_tier_matches(self):
|
async def test_cached_manifest_label_is_the_second_tier(self):
|
||||||
"""P5-1: the entity tier is below map/manifest — it serves when the
|
"""P5-1: below the explicit map sits the device manifest label."""
|
||||||
package has no explicit override and no manifest label yet."""
|
|
||||||
from types import SimpleNamespace as SimpleNS
|
|
||||||
|
|
||||||
import entity_stublib
|
import entity_stublib
|
||||||
|
|
||||||
modules = entity_stublib.load_entity_modules()
|
modules = entity_stublib.load_entity_modules()
|
||||||
player, _s, _i = _cmd_player(modules)
|
player, _s, _i = _cmd_player(modules)
|
||||||
player._entry.options["app_name_map"] = {} # no override
|
player._entry.options["app_name_map"] = {} # no override
|
||||||
player._androidtv_entity = "media_player.atv"
|
manager = player._manager
|
||||||
state = SimpleNS(attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"})
|
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
|
||||||
player.hass = SimpleNS(states=SimpleNS(get=lambda eid: state))
|
|
||||||
player._read = lambda eid: state if eid == "media_player.atv" else None
|
|
||||||
|
|
||||||
player._manager._on_current_app("ar.tvplayer.tv")
|
manager._on_current_app("ar.tvplayer.tv")
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
# no cached label yet → falls through to the entity tier
|
assert player.app_name == "TiviMate"
|
||||||
assert player.app_name == "ATV Tivi"
|
|
||||||
Reference in new issue
Block a user