feat(media_player): option-gated adb_command service (default off; registered at setup, unregistered at last unload, ADB-down rejection) + redacted entry diagnostics (P4-2a,b)
This commit is contained in:
1 parent
4222027602
commit
034cb5c83d
1 file changed
+133
@@ -24,6 +24,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
MediaPlayerDeviceClass,
|
MediaPlayerDeviceClass,
|
||||||
MediaPlayerEntity,
|
MediaPlayerEntity,
|
||||||
@@ -46,6 +47,7 @@ from .const import (
|
|||||||
ATTR_MEDIA_DAG_ROLE,
|
ATTR_MEDIA_DAG_ROLE,
|
||||||
ATTR_MEDIA_DAG_RUNNING_APPS,
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||||
CHANNEL_DIGIT_DELAY,
|
CHANNEL_DIGIT_DELAY,
|
||||||
|
CONF_ADB_COMMAND_ENABLED,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
CONF_APP_NAME_MAP,
|
CONF_APP_NAME_MAP,
|
||||||
@@ -58,6 +60,7 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
PROTOCOL_MEDIA_KEYS,
|
PROTOCOL_MEDIA_KEYS,
|
||||||
PROTOCOL_REMOTE_KEYS,
|
PROTOCOL_REMOTE_KEYS,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
TIVIMATE_PACKAGE,
|
TIVIMATE_PACKAGE,
|
||||||
)
|
)
|
||||||
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
||||||
@@ -183,6 +186,14 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True)
|
return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True)
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
|
# P4-2a: opt-in as an adb_command target (option default off).
|
||||||
|
# Registered at setup; unregistered at unload when the last
|
||||||
|
# participating entity goes away.
|
||||||
|
if self._entry.options.get(CONF_ADB_COMMAND_ENABLED, False):
|
||||||
|
entity_id = getattr(self, "entity_id", None) or self._attr_unique_id
|
||||||
|
_ADB_COMMAND_TARGETS[entity_id] = self._manager
|
||||||
|
_adb_command_service_ensure(self.hass)
|
||||||
|
|
||||||
# Discover power sensor siblings from the power switch
|
# Discover power sensor siblings from the power switch
|
||||||
if self._power_entity:
|
if self._power_entity:
|
||||||
self._power_sensors = discover_power_siblings(self.hass, self._power_entity)
|
self._power_sensors = discover_power_siblings(self.hass, self._power_entity)
|
||||||
@@ -218,6 +229,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
for task in self._entity_tasks:
|
for task in self._entity_tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
self._entity_tasks.clear()
|
self._entity_tasks.clear()
|
||||||
|
# P4-2a: drop this entity's service participation
|
||||||
|
entity_id = getattr(self, "entity_id", None) or self._attr_unique_id
|
||||||
|
if _ADB_COMMAND_TARGETS.pop(entity_id, None) is not None and self.hass:
|
||||||
|
_adb_command_service_maybe_remove(self.hass)
|
||||||
|
|
||||||
|
|
||||||
# ── Intent management ──
|
# ── Intent management ──
|
||||||
@@ -930,3 +945,121 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── P4-2a: the option-gated adb_command service ──
|
||||||
|
# Entities register their manager as a target when their entry enables the
|
||||||
|
# option; the domain service unregisters once the last participant unloads
|
||||||
|
# (core androidtv service posture: documented, opt-in, default off).
|
||||||
|
_ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {}
|
||||||
|
|
||||||
|
_ADB_COMMAND_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("command"): str,
|
||||||
|
vol.Optional("entity_id"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_adb_command_service(call) -> None:
|
||||||
|
"""Run a raw ADB shell command on the targeted entry's connection."""
|
||||||
|
command = str(call.data.get("command") or "").strip()
|
||||||
|
if not command:
|
||||||
|
_LOGGER.warning("adb_command: empty command ignored")
|
||||||
|
return
|
||||||
|
entity_id = call.data.get("entity_id")
|
||||||
|
pairs = [
|
||||||
|
(eid, manager)
|
||||||
|
for eid, manager in _ADB_COMMAND_TARGETS.items()
|
||||||
|
if entity_id in (None, eid)
|
||||||
|
]
|
||||||
|
if not pairs:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"adb_command: no participating entry matches %s (is the option "
|
||||||
|
"enabled on the entry's options?)",
|
||||||
|
entity_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
for _eid, manager in pairs:
|
||||||
|
poller = manager._poller
|
||||||
|
if poller is None or not manager.connected:
|
||||||
|
_LOGGER.warning("adb_command: ADB unavailable — command not sent")
|
||||||
|
continue
|
||||||
|
result = await poller.shell(command)
|
||||||
|
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def _adb_command_service_ensure(hass) -> None:
|
||||||
|
"""Register the domain service once the first participant appears."""
|
||||||
|
if not hass.services.has_service(DOMAIN, SERVICE_ADB_COMMAND):
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
|
_async_adb_command_service,
|
||||||
|
schema=_ADB_COMMAND_SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _adb_command_service_maybe_remove(hass) -> None:
|
||||||
|
"""Unregister when the last participant unloads."""
|
||||||
|
if not _ADB_COMMAND_TARGETS and hass.services.has_service(
|
||||||
|
DOMAIN, SERVICE_ADB_COMMAND
|
||||||
|
):
|
||||||
|
hass.services.async_remove(DOMAIN, SERVICE_ADB_COMMAND)
|
||||||
|
|
||||||
|
|
||||||
|
# ── P4-2b: diagnostics ──
|
||||||
|
|
||||||
|
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
||||||
|
_REDACTED = "**REDACTED**"
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(obj):
|
||||||
|
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {
|
||||||
|
key: (
|
||||||
|
_REDACTED
|
||||||
|
if any(m in str(key).lower() for m in _REDACT_KEY_MARKERS)
|
||||||
|
else _redact(value)
|
||||||
|
)
|
||||||
|
for key, value in obj.items()
|
||||||
|
}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_redact(item) for item in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_config_entry_diagnostics(hass, entry):
|
||||||
|
"""Entry diagnostics (P4-2b): redacted config + store + transport health.
|
||||||
|
|
||||||
|
HA resolves diagnostics from a ``diagnostics`` module; the function is
|
||||||
|
import-ready there — kept in this module until a pure-diagnostics file
|
||||||
|
is added (Phase 4 trim).
|
||||||
|
"""
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
manager = getattr(entry, "runtime_data", None)
|
||||||
|
payload: dict = {
|
||||||
|
"entry": {
|
||||||
|
"data": dict(getattr(entry, "data", {}) or {}),
|
||||||
|
"options": dict(getattr(entry, "options", {}) or {}),
|
||||||
|
},
|
||||||
|
"store": {},
|
||||||
|
"transport": {},
|
||||||
|
}
|
||||||
|
if manager is not None:
|
||||||
|
snapshot = getattr(manager.store, "_values", {})
|
||||||
|
for name, (stamp, value) in snapshot.items():
|
||||||
|
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
||||||
|
value = dataclasses.asdict(value)
|
||||||
|
payload["store"][name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
|
||||||
|
payload["transport"] = {
|
||||||
|
"adb": {"connected": manager.connected, "awake": manager.awake},
|
||||||
|
"protocol": {
|
||||||
|
"active": manager.protocol_active,
|
||||||
|
"available": manager.is_available,
|
||||||
|
"is_on": manager.is_on,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return _redact(payload)
|
||||||
Reference in new issue
Block a user