feat: standards polish — G7 sensor translation_key naming + entity strings, G10 protocol-push device identity on the registry card, G12 unrecorded poll-driven attributes (media player DAG internals + sensors), G14 async_redact_data with an explicit redaction list behind the marker scan (t30)

This commit is contained in:
James Mackay committed 2026-10-09 07:22:23 +01:00
1 parent 194a97ce34
commit 2e70587871
8 files changed
+178 -5

No files matched your search

@@ -215,6 +215,9 @@ class ShieldConnectionManager:
self._logcat_disabled = False self._logcat_disabled = False
self._logcat_failures: list[float] = [] self._logcat_failures: list[float] = []
self._logcat_last_pkg: str | None = None 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 self._logcat_last_stamp: float = -1e9
# Injectable ladder sleep (t26: deterministic tests / future alt-floors) # Injectable ladder sleep (t26: deterministic tests / future alt-floors)
self._logcat_ladder_sleep = asyncio.sleep self._logcat_ladder_sleep = asyncio.sleep
@@ -565,6 +568,24 @@ class ShieldConnectionManager:
self._app_label_requests.add(package) self._app_label_requests.add(package)
return None 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: async def _fetch_app_label(self, package: str) -> None:
"""One `pm dump` label fetch; failure falls through silently.""" """One `pm dump` label fetch; failure falls through silently."""
poller = self._poller poller = self._poller
@@ -997,6 +1018,14 @@ class ShieldConnectionManager:
foreground = await poller.poll_foreground() foreground = await poller.poll_foreground()
if foreground: if foreground:
self.store.set(FIELD_FOREGROUND, 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 # P5-1: manifest labels resolve on demand, one package per cycle
self._drain_app_label_requests() self._drain_app_label_requests()
# P5-3: ensure the (optional, default-off) logcat fast-path runs # P5-3: ensure the (optional, default-off) logcat fast-path runs
@@ -22,8 +22,19 @@ from .connection import ShieldConnectionManager
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material") _REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
_REDACTED = "**REDACTED**" _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): def _redact(obj):
"""Marker-based defensive pass (kept behind the async_redact_data helper)."""
"""Redact sensitive keys from a JSON-ish diagnostics payload.""" """Redact sensitive keys from a JSON-ish diagnostics payload."""
if isinstance(obj, dict): if isinstance(obj, dict):
return { return {
@@ -72,6 +83,14 @@ async def async_get_config_entry_diagnostics(
"is_on": manager.is_on, "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) return _redact(payload)
@@ -216,6 +216,22 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property @property
def device_info(self) -> DeviceInfo: 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( return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)}, identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title, name=self._entry.title,
@@ -794,7 +810,18 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
last_known=self._manager.last_known_map(), 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: def extra_state_attributes(self) -> dict:
# Now playing: prefer Jellyfin, fall back to ADB media session, # Now playing: prefer Jellyfin, fall back to ADB media session,
# then the foreground tier (P3-3a). # then the foreground tier (P3-3a).
+4 -2
View File
@@ -39,6 +39,8 @@ class NvidiaShieldSensorBase(SensorEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False _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: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass self.hass = hass
@@ -68,7 +70,7 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
(most recent visible ADB recent-task app). (most recent visible ADB recent-task app).
""" """
_attr_name = "Current App" _attr_translation_key = "current_app"
_attr_icon = "mdi:application" _attr_icon = "mdi:application"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -136,7 +138,7 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase): class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
"""What's currently playing via Jellyfin.""" """What's currently playing via Jellyfin."""
_attr_name = "Now Playing" _attr_translation_key = "now_playing"
_attr_icon = "mdi:play-circle" _attr_icon = "mdi:play-circle"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -132,5 +132,15 @@
"title": "ADB unavailable", "title": "ADB unavailable",
"message": "The ADB connection of {entity_id} is down \u2014 command not sent." "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"
}
}
} }
} }
@@ -132,5 +132,15 @@
"title": "ADB unavailable", "title": "ADB unavailable",
"message": "The ADB connection of {entity_id} is down \u2014 command not sent." "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"
}
}
} }
} }
+14 -1
View File
@@ -245,6 +245,11 @@ def install() -> None:
button = types.ModuleType("homeassistant.components.button") button = types.ModuleType("homeassistant.components.button")
button.ButtonEntity = type("ButtonEntity", (), {}) button.ButtonEntity = type("ButtonEntity", (), {})
sys.modules["homeassistant.components.button"] = button 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") helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry") entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
@@ -311,6 +316,7 @@ def load_entity_modules() -> dict:
"helpers", "helpers",
"power", "power",
"jellyfin", "jellyfin",
"sensor",
"media_player", "media_player",
"remote", "remote",
"diagnostics", "diagnostics",
@@ -323,7 +329,14 @@ def load_entity_modules() -> dict:
_load_module(f"nsh.{name}", _SHIELD_DIR / file_name) _load_module(f"nsh.{name}", _SHIELD_DIR / file_name)
return { return {
name: sys.modules[f"nsh.{name}"] 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",
)
} }
+64 -1
View File
@@ -102,7 +102,7 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
mp_module = modules["media_player"] mp_module = modules["media_player"]
if manager is None: if manager is None:
manager = make_manager() manager = make_manager()
entry = SimpleNamespace(options={}, data={}, title="mdarcy") entry = SimpleNamespace(entry_id="e1", options={}, data={}, title="mdarcy")
manager.entry = entry manager.entry = entry
def _set_option(key, value): def _set_option(key, value):
@@ -754,6 +754,69 @@ class _SetupHass:
self.services = services 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: class TestChannelDigitDelayConsumed:
"""t26 F3: a non-default digit delay reaches the sequence's sleep.""" """t26 F3: a non-default digit delay reaches the sequence's sleep."""