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:
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_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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user