feat(adb_command): selectable target, strict resolution, and a real response (t14)

The user's live run could not select his Shield for
nvidia_shield.adb_command, so he ran his diagnostics through the deprecated
androidtv integration instead. Measured against the installed HA (2026.10.1),
the cause is a contract, not a bug in the handler:

* ServiceRegistry.async_register has NO target parameter, so a target cannot be
  declared at registration;
* the frontend renders the target picker from the service DESCRIPTION, which
  HA builds from services.yaml or from the runtime async_set_service_schema —
  and the runtime description WINS.

So async_setup now registers with supports_response=OPTIONAL and publishes
ADB_COMMAND_SERVICE_DESCRIPTION (target: {entity: [{domain: media_player}]},
fields, prose mirrored in strings.json/translations/en.json and pinned against
both by a test). No services.yaml is needed, and none is added.

The target resolution is strict, because the failure mode of guessing is a raw
shell command on the wrong device: exactly one PARTICIPANT, told apart as
no_target (nothing selected), unsupported_target (not a participant / option
off / another integration's entity) and ambiguous_target (two selected — sent
to neither). Accepts what HA actually sends: entity_id as text or as the LIST a
picked target produces, and device_id (an automation targeting the DEVICE),
expanded only through this integration's own registry entries and only to media
players. The output now comes back in the response under adb_response — the key
the user's captures read — while an unreachable Shield stays adb_unavailable,
an error rather than an empty response, and a call that does not ask for a
response is unchanged.

Evidence: 12 real-HA tests (new tests/test_adb_command_real_ha.py) drive the
real service registry, the real get_services websocket payload (the exact JSON
bytes the browser parses: target present, response optional, both fields) and
real async_call for every shape above. t14's CI gate globs tests/test_*real_ha.py
so a new real-HA file cannot skip silently; the skip/empty-collection guards stay
and were executed. The real-HA probe also caught async_entries_for_device being
a MODULE-LEVEL helper (not a registry method) that the stub had hidden, so the
drift gate's owner map now covers async_register, async_set_service_schema and
async_entries_for_device, with the call-argument floor raised 44 -> 52.

Verify: suites 681/4 and 682/3; real-HA 38 + the new file 12 (50 with the glob);
ruff clean; drift exit 0 (54 call arguments).
This commit is contained in:
James Mackay committed 2026-10-12 11:23:47 +01:00
1 parent 8a4e72b80a
commit 6aa3b6bfdd
13 files changed
+1087 -56

No files matched your search

+23 -1
View File
@@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant
from .adb_session import AdbMediaSessionPoller
from .connection import ShieldConnectionManager
from .const import (
ADB_COMMAND_SERVICE_DESCRIPTION,
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
@@ -80,14 +81,35 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
data=block,
)
)
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS.
# The two APIs the target-bearing registration needs are imported WITH that
# registration, next to the handler they belong to (t14): importing the package —
# which every config-flow harness does via
# ``custom_components.nvidia_shield.config_flow`` — must not drag in the service
# registration surface for a domain that harness never sets up.
from homeassistant.core import SupportsResponse
from homeassistant.helpers.service import async_set_service_schema
from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service
# t14: the action must be SELECTABLE for the user's Shield. Measured on HA
# 2026.10.1: `async_register` has no `target` parameter, so the UI's target
# picker comes from the service DESCRIPTION — the runtime
# `async_set_service_schema` (which wins over a services.yaml we do not ship)
# carries `target: {entity: [{domain: media_player}]}`, so the picker offers the
# Shield's media player entity. `supports_response=OPTIONAL` is registered FIRST
# because async_set_service_schema reads `hass.services.supports_response()` to
# publish the response flag, and the handler returns the command's output under
# ADB_COMMAND_RESPONSE_KEY.
hass.services.async_register(
DOMAIN,
SERVICE_ADB_COMMAND,
_async_adb_command_service,
schema=_ADB_COMMAND_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
async_set_service_schema(
hass, DOMAIN, SERVICE_ADB_COMMAND, ADB_COMMAND_SERVICE_DESCRIPTION
)
return True
+60 -1
View File
@@ -147,11 +147,70 @@ APP_LABEL_GREP_SHAPES = "application-label|nonLocalizedLabel|labelRes"
# found anywhere resets the run.
APP_LABEL_PROBE_LIMIT = 5
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
# P4-2a adb_command service: raw ADB shell on this entry — OFF by default
# (same security posture as the core androidtv integration).
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
SERVICE_ADB_COMMAND = "adb_command"
#: t14: the key the command's output is returned under, in the service RESPONSE.
#: It matches the deprecated ``androidtv.adb_command`` the user fell back to, so a
#: template written against that integration keeps reading the same field.
ADB_COMMAND_RESPONSE_KEY = "adb_response"
#: t14: the picker the UI offers for `nvidia_shield.adb_command`.
#:
#: MEASURED on HA 2026.10.1: ``ServiceRegistry.async_register`` has NO ``target``
#: parameter, so a target cannot be declared at registration. The frontend renders
#: the target picker from the service DESCRIPTION instead (``services.yaml``, or a
#: runtime ``async_set_service_schema`` call, which WINS over services.yaml) — this
#: is that description's ``target``. Only the ENTITY kind is declared: the Shield is
#: selected as its ``media_player`` entity (one per entry), exactly like the
#: deprecated ``androidtv.adb_command`` the user used to fall back to.
#:
#: The entity domain the action's target declares, and the only domain a
#: ``device_id`` expands to (a Shield device also carries sensors, a remote, an
#: image entity — none of those is something to run a raw shell command on).
ADB_COMMAND_TARGET_DOMAIN = "media_player"
ADB_COMMAND_SERVICE_TARGET: dict = {
"entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}]
}
#: t14: the service DESCRIPTION registered at setup (``async_set_service_schema``).
#:
#: Why a runtime description at all: the description is what the frontend receives
#: from ``get_services``, and a service with no description has no ``target``, which
#: is precisely why the user's UI offered no Shield to select and he had to run his
#: diagnostics through the deprecated integration. The prose here mirrors
#: ``strings.json``/``translations/en.json`` (where HA's translations live) so the
#: form renders even before a translation is loaded; ``tests/test_adb_command_target.py``
#: asserts the two cannot drift.
ADB_COMMAND_SERVICE_DESCRIPTION: dict = {
"name": "ADB command",
"description": (
"Send a raw ADB shell command to the persistent connection of an Nvidia "
"Shield entry (option-gated use)."
),
"target": ADB_COMMAND_SERVICE_TARGET,
"fields": {
"command": {
"name": "Command",
"description": "The shell command to run on the device.",
"required": True,
"selector": {"text": {}},
},
"entity_id": {
"name": "Entity",
"description": (
"The Shield to run it on: its media_player entity. Select it in "
"the target picker above, or pass entity_id (or the Shield's "
"device_id) in YAML."
),
"selector": {"entity": {"domain": "media_player"}},
},
},
}
# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer
# (foreground AppState fast-path). Default OFF; the user flips it on and
# reports stability on their device.
+115 -12
View File
@@ -81,15 +81,26 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
STATE_OFF,
STATE_ON,
STATE_PAUSED,
STATE_PLAYING,
)
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
from .connection import ShieldConnectionManager
from .const import (
ADB_COMMAND_RESPONSE_KEY,
ADB_COMMAND_TARGET_DOMAIN,
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
@@ -113,6 +124,7 @@ from .const import (
SCRIPT_VAR_APP_NAME,
SCRIPT_VAR_LEVEL,
SCRIPT_VAR_MUTED,
SERVICE_ADB_COMMAND,
TIVIMATE_PACKAGE,
command_scripts,
)
@@ -1739,35 +1751,126 @@ _ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {}
_ADB_COMMAND_SCHEMA = vol.Schema(
{
vol.Required("command"): str,
vol.Required("entity_id"): str,
# t14: the UI's target picker sends the SELECTED Shield as `entity_id`
# (a string, or a LIST — HA's picker sends a list for a picked target),
# and an automation that targets by device sends `device_id`. Both arrive
# here as text-or-list and are resolved to exactly ONE participant in the
# handler: a command must never be sent to a Shield the user did not pick,
# and "which one?" is never guessed.
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
vol.Optional(ATTR_DEVICE_ID): cv.ensure_list_csv,
}
)
async def _async_adb_command_service(call) -> None:
"""Run a raw ADB shell command on the target connection (t29 G8).
def _requested_adb_command_targets(call) -> list[str]:
"""The entity ids the caller selected, in order, deduplicated (t14).
TWO shapes reach here, both of them HA's own: the UI's target picker merges the
selection into the call as ``entity_id``, and an automation written against a
device target sends ``device_id``. A value is a string or a list (the picker
sends a list).
A device_id expands ONLY through this integration's own registry entries, so a
device id can never pull in an unrelated integration's entities — and with no
registry to consult it expands to nothing (an error naming the target, never a
guess).
"""
requested: list[str] = []
for value in cv.ensure_list(call.data.get(ATTR_ENTITY_ID)):
text = str(value).strip()
if text:
requested.append(text)
device_ids = cv.ensure_list(call.data.get(ATTR_DEVICE_ID))
if device_ids:
hass = getattr(call, "hass", None)
registry = er.async_get(hass) if hass is not None else None
for device_id in device_ids:
text = str(device_id).strip()
if not text or registry is None:
continue
# MEASURED on HA 2026.10.1: ``async_entries_for_device`` is a
# MODULE-LEVEL helper that takes the registry — the EntityRegistry class
# does not carry the method — and only the real-HA file drives it.
for entry in er.async_entries_for_device(registry, text):
# A Shield DEVICE carries sensors, a remote and an image entity too;
# only the media player is something this action can act on.
if entry.domain == ADB_COMMAND_TARGET_DOMAIN:
requested.append(entry.entity_id)
return list(dict.fromkeys(requested))
def _single_adb_command_target(call) -> str:
"""Exactly ONE participating Shield, or a ServiceValidationError (t14).
The three failures are told apart because the fix differs: nothing selected
(``no_target`` — pick the Shield), something selected that does not participate
(``unsupported_target`` — the entry's option is off, or it is another
integration's entity), and more than one selected (``ambiguous_target`` — a raw
shell command is never broadcast, and never aimed at the wrong Shield).
"""
requested = _requested_adb_command_targets(call)
if not requested:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_target",
translation_placeholders={"service": f"{DOMAIN}.{SERVICE_ADB_COMMAND}"},
)
unknown = [
entity_id for entity_id in requested if entity_id not in _ADB_COMMAND_TARGETS
]
if unknown:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_target",
translation_placeholders={"entity_id": ", ".join(unknown)},
)
if len(requested) > 1:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="ambiguous_target",
translation_placeholders={"entity_id": ", ".join(requested)},
)
return requested[0]
async def _async_adb_command_service(call):
"""Run a raw ADB shell command on the SELECTED Shield (t29 G8, t14).
The integration's async_setup registers this ALWAYS; the entry's
adb_command_enabled option gates USE (participants register from
async_added_to_hass). Failures raise ServiceValidationError with
translated keys (action-exceptions) — never warning logs.
t14 — THE TARGET. The service is registered domain-wide with a runtime
description whose ``target`` is the Shield's ``media_player`` entity, so the UI
offers a picker; HA merges whatever was picked into ``call.data`` as
``entity_id`` and/or ``device_id`` (a string, or the list a picked target
produces). Resolution is deliberately strict — exactly one participant, or a
ServiceValidationError naming what to pass — because the alternative (picking a
Shield for the user) is a raw shell command sent to the wrong device.
THE RESPONSE (t14). The output comes back under ``adb_response``, the same key
the deprecated ``androidtv.adb_command`` returned, so a template written
against that integration keeps working. Callers that do not ask for a response
are unaffected: HA drops the value, and the registration is OPTIONAL (never
"only"). An unreachable Shield is an error (``adb_unavailable``), not an empty
response.
"""
command = str(call.data.get("command") or "").strip()
if not command:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="invalid_command"
)
entity_id = call.data.get("entity_id")
manager = _ADB_COMMAND_TARGETS.get(entity_id)
if manager is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_target",
)
entity_id = _single_adb_command_target(call)
manager = _ADB_COMMAND_TARGETS[entity_id]
poller = manager.adb_poller # public accessor (t21 F6)
if poller is None or not manager.connected:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="adb_unavailable"
translation_domain=DOMAIN,
translation_key="adb_unavailable",
translation_placeholders={"entity_id": entity_id},
)
result = await poller.shell(command)
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
return {ADB_COMMAND_RESPONSE_KEY: result}
+9 -1
View File
@@ -344,7 +344,7 @@
},
"entity_id": {
"name": "Entity",
"description": "The Nvidia Shield media player to target."
"description": "The Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
}
}
}
@@ -369,6 +369,14 @@
"command_script_failed": {
"title": "Command script failed",
"message": "The script configured for {action} ({script}) failed: {error}"
},
"no_target": {
"title": "No Shield selected",
"message": "Select the Shield to run this on: the target picker's entity (or pass entity_id / device_id in YAML). {service} sends nothing without a target."
},
"ambiguous_target": {
"title": "More than one Shield selected",
"message": "{entity_id} covers more than one adb_command target — select exactly one Shield (one media_player entity) — command not sent."
}
},
"entity": {
@@ -344,7 +344,7 @@
},
"entity_id": {
"name": "Entity",
"description": "The Nvidia Shield media player to target."
"description": "The Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
}
}
}
@@ -369,6 +369,14 @@
"command_script_failed": {
"title": "Command script failed",
"message": "The script configured for {action} ({script}) failed: {error}"
},
"no_target": {
"title": "No Shield selected",
"message": "Select the Shield to run this on: the target picker's entity (or pass entity_id / device_id in YAML). {service} sends nothing without a target."
},
"ambiguous_target": {
"title": "More than one Shield selected",
"message": "{entity_id} covers more than one adb_command target — select exactly one Shield (one media_player entity) — command not sent."
}
},
"entity": {