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

+84 -1
View File
@@ -28,6 +28,8 @@ ENTITIES_STUB_NAMES = (
"homeassistant.components.button",
"homeassistant.components.image",
"homeassistant.helpers",
"homeassistant.helpers.service",
"homeassistant.helpers.config_validation",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector",
@@ -45,7 +47,9 @@ ENTITIES_STUB_NAMES = (
"nsh.remote",
"nsh.image",
)
#: t14: every runtime service description registered through the stub face below,
#: as ``(domain, service, schema)``. A test clears it to see only its own call.
SERVICE_SCHEMA_CALLS: list = []
class EntitySelectorStub:
"""Stand-in for HA's EntitySelector that REJECTS '' and None, like the real one.
@@ -420,6 +424,12 @@ def install() -> None:
core.HomeAssistant = object
core.callback = lambda fn: fn
core.CALLBACK_TYPE = object
# t14: the adb_command registration asks for OPTIONAL response support (the
# handler returns the command's output). The stub keeps HA's three values
# distinct so a test can tell "optional" from "only".
core.SupportsResponse = types.SimpleNamespace(
NONE="none", OPTIONAL="optional", ONLY="only"
)
core.Event = object
sys.modules["homeassistant.core"] = core
@@ -431,6 +441,11 @@ def install() -> None:
# two switch states have to exist with their real values.
ha_const.STATE_ON = "on"
ha_const.STATE_OFF = "off"
# t14: the adb_command target arrives as these two service-call fields (the UI's
# target picker merges the selection in as entity_id, an automation targeting a
# device sends device_id), so the entity service schema names them.
ha_const.ATTR_ENTITY_ID = "entity_id"
ha_const.ATTR_DEVICE_ID = "device_id"
ha_const.Platform = types.SimpleNamespace(
MEDIA_PLAYER="media_player",
SENSOR="sensor",
@@ -558,15 +573,83 @@ def install() -> None:
helpers = types.ModuleType("homeassistant.helpers")
# t14: ``async_set_service_schema`` publishes the runtime service DESCRIPTION —
# the thing the frontend renders the target picker from (HA's cache is what
# get_services serves). The stub records the call instead, so a test can assert
# what the integration told HA to offer for nvidia_shield.adb_command.
service = types.ModuleType("homeassistant.helpers.service")
def _stub_set_service_schema(hass, domain, service_name, schema):
SERVICE_SCHEMA_CALLS.append((domain, service_name, schema))
service.async_set_service_schema = _stub_set_service_schema
helpers.service = service
sys.modules["homeassistant.helpers.service"] = service
# t14: the entity service schema names its two target fields through
# ``cv.entity_ids`` / ``cv.ensure_list_csv``. The stub keeps HA's SHAPE (None ->
# [], text or list -> list, comma text -> split) without the real entity-id
# format check — that check belongs to the installed HA and is exercised by
# tests/test_adb_command_real_ha.py. The schema is not executed in the stub
# suite anyway: those tests call the handler directly.
config_validation = types.ModuleType("homeassistant.helpers.config_validation")
def _ensure_list(value):
if value is None:
return []
if isinstance(value, (list, tuple)):
return list(value)
return [value]
def _ensure_list_csv(value):
if isinstance(value, str):
return [part.strip() for part in value.split(",") if part.strip()]
return _ensure_list(value)
config_validation.ensure_list = _ensure_list
config_validation.ensure_list_csv = _ensure_list_csv
config_validation.entity_ids = _ensure_list_csv
helpers.config_validation = config_validation
sys.modules["homeassistant.helpers.config_validation"] = config_validation
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
class _RegistryEntry:
"""The two attributes the integration reads off a registry entry (t14)."""
def __init__(self, entity_id):
self.entity_id = entity_id
self.domain = entity_id.split(".", 1)[0]
class _Registry:
entities: dict = {}
#: t14: ``device_id -> [entity_id, ...]``, what HA's registry knows about a
#: device. Seeded by the test that needs a device target; ``async_get``
#: keeps returning a fresh instance, so the binding lives on the class.
bindings: dict = {}
def async_get(self, entity_id):
return None
def _stub_entries_for_device(registry, device_id, include_disabled_entities=False):
"""t14: HA's device->entities helper is a MODULE-LEVEL function, not a method
on the registry (measured on 2026.10.1) — the stub mirrors that shape, because
a stub that put it on the class hid the AttributeError the real API raised.
"""
del registry, include_disabled_entities
return [_RegistryEntry(entry) for entry in _Registry.bindings.get(device_id, [])]
entity_registry.async_get = lambda hass: _Registry()
entity_registry.async_entries_for_device = _stub_entries_for_device
# t14: how a test gives the stub registry a device -> entities binding (the real
# one is filled by HA's own entity registry) and how it takes it back out.
def _stub_bind_device(device_id, *entity_ids):
_Registry.bindings[device_id] = list(entity_ids)
entity_registry.bind_device = _stub_bind_device
entity_registry.reset_bindings = lambda: _Registry.bindings.clear()
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
class _DeviceRegistry: