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