diff --git a/custom_components/nvidia_shield/connection.py b/custom_components/nvidia_shield/connection.py index f1d2f99..a0ea8aa 100644 --- a/custom_components/nvidia_shield/connection.py +++ b/custom_components/nvidia_shield/connection.py @@ -215,6 +215,9 @@ class ShieldConnectionManager: self._logcat_disabled = False self._logcat_failures: list[float] = [] self._logcat_last_pkg: str | None = None + # G10 (t30): protocol device identity captured once per session — + # (name, manufacturer, model, sw) for the registry's device card. + self.protocol_device_data: tuple[str, str, str, str] | None = None self._logcat_last_stamp: float = -1e9 # Injectable ladder sleep (t26: deterministic tests / future alt-floors) self._logcat_ladder_sleep = asyncio.sleep @@ -565,6 +568,24 @@ class ShieldConnectionManager: self._app_label_requests.add(package) return None + async def async_capture_protocol_device_data(self) -> None: + """Capture (name, manufacturer, model, sw) once (t30 G10).""" + if self._protocol is None or self.protocol_device_data is not None: + return + try: + info = self._protocol.device_info or {} # {manufacturer, model, sw_version} + name, _mac = await asyncio.wait_for( + self._protocol.async_get_name_and_mac(), timeout=10.0 + ) + self.protocol_device_data = ( + name, + info.get("manufacturer", "Google"), + info.get("model", "Shield TV"), + info.get("sw_version", ""), + ) + except Exception as err: + _LOGGER.debug("protocol device info unavailable: %s", err) + async def _fetch_app_label(self, package: str) -> None: """One `pm dump` label fetch; failure falls through silently.""" poller = self._poller @@ -997,6 +1018,14 @@ class ShieldConnectionManager: foreground = await poller.poll_foreground() if foreground: self.store.set(FIELD_FOREGROUND, foreground) + # G10: capture the protocol device identity for the registry card + if self._protocol is not None: + self._tasks.append( + self._create_task( + self.async_capture_protocol_device_data(), + "protocol_device_capture", + ) + ) # P5-1: manifest labels resolve on demand, one package per cycle self._drain_app_label_requests() # P5-3: ensure the (optional, default-off) logcat fast-path runs diff --git a/custom_components/nvidia_shield/diagnostics.py b/custom_components/nvidia_shield/diagnostics.py index 682c2ad..fd6de07 100644 --- a/custom_components/nvidia_shield/diagnostics.py +++ b/custom_components/nvidia_shield/diagnostics.py @@ -22,8 +22,19 @@ from .connection import ShieldConnectionManager _REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material") _REDACTED = "**REDACTED**" +# G14: the documented explicit redaction field list (host paths stay +# readable; pairing/key-shaped fields are masked by async_redact_data). +_TO_REDACT = { + "pairing_code", + "adb_pairing_code", + "protocol_pairing_code", + "serial_number", + "uuid", +} + def _redact(obj): + """Marker-based defensive pass (kept behind the async_redact_data helper).""" """Redact sensitive keys from a JSON-ish diagnostics payload.""" if isinstance(obj, dict): return { @@ -72,6 +83,14 @@ async def async_get_config_entry_diagnostics( "is_on": manager.is_on, }, } + # G14 (t30): the documented helper is the primary redaction path with + # an explicit field list; the marker scan above runs defensively after. + try: + from homeassistant.util.diagnostic import async_redact_data + except ImportError: + async_redact_data = None + if async_redact_data is not None: + payload = async_redact_data(payload, _TO_REDACT) return _redact(payload) diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 2bbadea..f18e95d 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -216,6 +216,22 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): @property def device_info(self) -> DeviceInfo: + # G10 (t30): when the protocol transport is live and captured, the + # registry card carries the protocol-pushed identity (name from the + # device itself, manufacturer 'Google' per the audit ruling); the + # pre-capture shape stays the entry-title fallback. + captured = ( + self._manager.protocol_device_data if self._manager is not None else None + ) + if captured: + name, manufacturer, model, sw = captured + return DeviceInfo( + identifiers={(DOMAIN, self._entry.entry_id)}, + name=name or self._entry.title, + manufacturer=manufacturer, + model=model, + sw_version=sw or None, + ) return DeviceInfo( identifiers={(DOMAIN, self._entry.entry_id)}, name=self._entry.title, @@ -794,7 +810,18 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): last_known=self._manager.last_known_map(), ) - @property + # G12 (t30): attributes that change with every poll must not bloat the + # database (entity doc) — dag internals are declared unrecorded. + _unrecorded_attributes = frozenset( + { + ATTR_MEDIA_DAG_NOW_PLAYING, + ATTR_MEDIA_DAG_RUNNING_APPS, + ATTR_MEDIA_DAG_RECEIVER_ENTITY, + ATTR_MEDIA_DAG_RECEIVER_INPUT, + ATTR_MEDIA_DAG_ROLE, + } + ) + def extra_state_attributes(self) -> dict: # Now playing: prefer Jellyfin, fall back to ADB media session, # then the foreground tier (P3-3a). diff --git a/custom_components/nvidia_shield/sensor.py b/custom_components/nvidia_shield/sensor.py index 9a3a95c..c0431d4 100644 --- a/custom_components/nvidia_shield/sensor.py +++ b/custom_components/nvidia_shield/sensor.py @@ -39,6 +39,8 @@ 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"}) def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.hass = hass @@ -68,7 +70,7 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase): (most recent visible ADB recent-task app). """ - _attr_name = "Current App" + _attr_translation_key = "current_app" _attr_icon = "mdi:application" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -136,7 +138,7 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase): class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase): """What's currently playing via Jellyfin.""" - _attr_name = "Now Playing" + _attr_translation_key = "now_playing" _attr_icon = "mdi:play-circle" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index b54a414..24b71e0 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -132,5 +132,15 @@ "title": "ADB unavailable", "message": "The ADB connection of {entity_id} is down \u2014 command not sent." } + }, + "entity": { + "sensor": { + "current_app": { + "name": "Current app" + }, + "now_playing": { + "name": "Now playing" + } + } } } diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index b54a414..24b71e0 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -132,5 +132,15 @@ "title": "ADB unavailable", "message": "The ADB connection of {entity_id} is down \u2014 command not sent." } + }, + "entity": { + "sensor": { + "current_app": { + "name": "Current app" + }, + "now_playing": { + "name": "Now playing" + } + } } } diff --git a/tests/entity_stublib.py b/tests/entity_stublib.py index d24aa1c..a757de7 100644 --- a/tests/entity_stublib.py +++ b/tests/entity_stublib.py @@ -245,6 +245,11 @@ def install() -> None: button = types.ModuleType("homeassistant.components.button") button.ButtonEntity = type("ButtonEntity", (), {}) sys.modules["homeassistant.components.button"] = button + sensor_platform = types.ModuleType("homeassistant.components.sensor") + sensor_platform.SensorEntity = type("SensorEntity", (), {}) + sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app") + sys.modules["homeassistant.components.sensor"] = sensor_platform + helpers = types.ModuleType("homeassistant.helpers") entity_registry = types.ModuleType("homeassistant.helpers.entity_registry") @@ -311,6 +316,7 @@ def load_entity_modules() -> dict: "helpers", "power", "jellyfin", + "sensor", "media_player", "remote", "diagnostics", @@ -323,7 +329,14 @@ def load_entity_modules() -> dict: _load_module(f"nsh.{name}", _SHIELD_DIR / file_name) return { name: sys.modules[f"nsh.{name}"] - for name in ("power", "jellyfin", "media_player", "remote", "diagnostics") + for name in ( + "power", + "jellyfin", + "sensor", + "media_player", + "remote", + "diagnostics", + ) } diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index 202a442..51c16ee 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -102,7 +102,7 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None): mp_module = modules["media_player"] if manager is None: manager = make_manager() - entry = SimpleNamespace(options={}, data={}, title="mdarcy") + entry = SimpleNamespace(entry_id="e1", options={}, data={}, title="mdarcy") manager.entry = entry def _set_option(key, value): @@ -754,6 +754,69 @@ class _SetupHass: self.services = services +class TestRegistryCardG10: + """t30 G10: the protocol-pushed identity builds the registry card.""" + + async def test_device_info_carries_the_captured_identity(self, modules): + player, _s, _i = make_player(modules) + player._manager.protocol_device_data = ( + "Living Room Shield", "Google", "SHIELD Android TV", "9.2" + ) + + info = player.device_info + + assert info["name"] == "Living Room Shield" + assert info["manufacturer"] == "Google" + assert info["model"] == "SHIELD Android TV" + assert info["sw_version"] == "9.2" + + async def test_fallback_card_before_capture(self, modules): + player, _s, _i = make_player(modules) + + info = player.device_info + + assert info["name"] == "mdarcy" # the entry title stays + assert info["manufacturer"] == "NVIDIA" + assert info["model"] == "Shield TV" + + async def test_capture_runs_once_per_session(self, modules): + manager = make_player(modules)[0]._manager + + calls: list[str] = [] + + class _Proto: + device_info = {"manufacturer": "Google", "model": "SHIELD", "sw_version": "9.2"} + + async def async_get_name_and_mac(self): + calls.append("1") + return "Living Room Shield", "aa:bb:cc:dd:ee:ff" + + manager._protocol = _Proto() + + await manager.async_capture_protocol_device_data() + await manager.async_capture_protocol_device_data() + + assert calls == ["1"] # cached after the first capture + assert manager.protocol_device_data[1] == "Google" + + +class TestRecorderHygieneG12: + """t30 G12: poll-driven attributes are declared unrecorded.""" + + def test_media_player_declares_dag_internals(self, modules): + player, _s, _i = make_player(modules) + + declared = type(player)._unrecorded_attributes + assert "media_dag_now_playing" in declared # dag internals (per-PAq) + assert "media_dag_running_apps" in declared + + def test_sensors_declare_poll_attributes(self, modules): + from nsh.sensor import NvidiaShieldSensorBase + + declared = NvidiaShieldSensorBase._unrecorded_attributes + assert {"app_id", "app_name", "playback_state"} <= set(declared) + + class TestChannelDigitDelayConsumed: """t26 F3: a non-default digit delay reaches the sequence's sleep."""