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

+169 -7
View File
@@ -972,12 +972,20 @@ class FakeServices:
def __init__(self):
self.registered: dict[tuple, object] = {}
self.service_calls: list[tuple] = []
#: t14: what each registration declared for the response.
self.response_support: dict[tuple, object] = {}
def has_service(self, domain, service):
return (domain, service) in self.registered
def async_register(self, domain, service, func, schema=None):
def async_register(
self, domain, service, func, schema=None, supports_response=None
):
"""t14: keyword-faithful to HA — ``supports_response`` is passed here."""
self.registered[(domain, service)] = (func, schema)
#: Kept beside the 2-tuple (readers assert on that shape) so a test can see
#: what the registration declared for the response (t14: OPTIONAL).
self.response_support[(domain, service)] = supports_response
def async_remove(self, domain, service):
self.registered.pop((domain, service), None)
@@ -1032,21 +1040,171 @@ class TestAdbCommandService:
player._manager._poller.shell = _shell
# missing entity_id (required target) → validation error
with pytest.raises(ServiceValidationError):
# nothing selected → the target-less error, naming what to pass
with pytest.raises(ServiceValidationError) as err:
await _async_adb_command_service(_Call({"command": "echo hi"}))
assert err.value.translation_key == "no_target"
# unknown entity → unsupported_target
with pytest.raises(ServiceValidationError):
with pytest.raises(ServiceValidationError) as err:
await _async_adb_command_service(
_Call({"command": "echo hi", "entity_id": "media_player.other"})
)
# valid target → the shell runs
await _async_adb_command_service(
assert err.value.translation_key == "unsupported_target"
# valid target → the shell runs, and the output comes back in the RESPONSE
result = await _async_adb_command_service(
_Call({"command": "echo hi", "entity_id": "media_player.shield_test"})
)
assert shell_calls == ["echo hi"]
assert result == {"adb_response": "ok"}
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
async def test_the_ui_target_shapes_resolve_to_exactly_one_shield(self, modules):
"""t14: the picker's value (text OR list) selects; two is an error.
HA merges the UI's target into the call as ``entity_id``; a picked target
arrives as a LIST. Both shapes must reach the same single Shield, and a
selection that covers two Shields must send nothing.
"""
player, _s = self._participant(modules, enabled=True)
from homeassistant.exceptions import ServiceValidationError
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
_ADB_COMMAND_TARGETS["media_player.other"] = make_player(modules)[0]._manager
shell_calls = []
async def _shell(cmd):
shell_calls.append(cmd)
return "out"
player._manager._poller.shell = _shell
# the string form a YAML caller passes
assert await _async_adb_command_service(
_Call({"command": "echo a", "entity_id": "media_player.shield_test"})
) == {"adb_response": "out"}
# the list form the UI's picker produces for one picked entity
assert await _async_adb_command_service(
_Call({"command": "echo b", "entity_id": ["media_player.shield_test"]})
) == {"adb_response": "out"}
assert shell_calls == ["echo a", "echo b"]
# two selected → ambiguous_target, and NOTHING was sent to either
with pytest.raises(ServiceValidationError) as err:
await _async_adb_command_service(
_Call({
"command": "echo c",
"entity_id": ["media_player.shield_test", "media_player.other"],
})
)
assert err.value.translation_key == "ambiguous_target"
assert shell_calls == ["echo a", "echo b"]
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
_ADB_COMMAND_TARGETS.pop("media_player.other", None)
async def test_a_device_target_resolves_through_the_entity_registry(self, modules):
"""t14: `device_id` (what an automation targeting a DEVICE sends) works.
It expands only through this integration's own registry entries and only to
media players, so a device that carries no participant is an error, never a
guess.
"""
player, _s = self._participant(modules, enabled=True)
from homeassistant.exceptions import ServiceValidationError
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
registry = sys.modules["homeassistant.helpers.entity_registry"]
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
registry.bind_device("dev_shield", "media_player.shield_test")
# the same device shape the real registry produces: sensors next to the player,
# and a player whose entry has the option OFF (not a participant)
registry.bind_device("dev_with_sensors", "media_player.shield_test", "sensor.shield_temp")
registry.bind_device("dev_off", "media_player.shield_off")
registry.bind_device("dev_sensor", "sensor.shield_temperature")
shell_calls = []
async def _shell(cmd):
shell_calls.append(cmd)
return "via device"
player._manager._poller.shell = _shell
try:
assert await _async_adb_command_service(
_Call({"command": "echo d", "device_id": "dev_shield"}, player.hass)
) == {"adb_response": "via device"}
assert shell_calls == ["echo d"]
# a device whose media player does not participate → error naming it
with pytest.raises(ServiceValidationError) as err:
await _async_adb_command_service(
_Call({"command": "echo e", "device_id": "dev_off"}, player.hass)
)
assert err.value.translation_key == "unsupported_target"
assert "media_player.shield_off" in err.value.translation_placeholders[
"entity_id"
]
assert shell_calls == ["echo d"]
# ...while the sensors of a participating device do not become targets
assert await _async_adb_command_service(
_Call(
{"command": "echo f", "device_id": "dev_with_sensors"},
player.hass,
)
) == {"adb_response": "via device"}
assert shell_calls == ["echo d", "echo f"]
# a device with no media player at all cannot be a target
with pytest.raises(ServiceValidationError) as err:
await _async_adb_command_service(
_Call({"command": "echo g", "device_id": "dev_sensor"}, player.hass)
)
assert err.value.translation_key == "no_target"
assert shell_calls == ["echo d", "echo f"]
finally:
registry.reset_bindings()
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
async def test_the_registration_publishes_the_target_and_response(self, modules):
"""t14: what async_setup tells HA — the picker and the response flag."""
_player, services = self._participant(modules, enabled=False)
from nsh.const import (
ADB_COMMAND_SERVICE_DESCRIPTION,
ADB_COMMAND_SERVICE_TARGET,
)
init_module = sys.modules.get("nsh.init")
if init_module is None:
import conftest
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
init_module = sys.modules["nsh.init"]
await init_module.async_setup(_SetupHass(services), {})
# the registration declares that a RESPONSE is available (OPTIONAL: callers
# that do not ask are unaffected)
assert (
services.response_support[("nvidia_shield", "adb_command")] == "optional"
)
# and the runtime DESCRIPTION — the only place a target can be declared —
# carries the picker: the Shield is selected as its media_player entity
calls = [
call
for call in entity_stublib.SERVICE_SCHEMA_CALLS
if call[0] == "nvidia_shield" and call[1] == "adb_command"
]
assert calls, "async_setup must publish the adb_command description"
schema = calls[-1][2]
assert schema is ADB_COMMAND_SERVICE_DESCRIPTION
assert schema["target"] is ADB_COMMAND_SERVICE_TARGET
assert schema["target"] == {"entity": [{"domain": "media_player"}]}
async def test_empty_command_raises_validation_error(self, modules):
from homeassistant.exceptions import ServiceValidationError
from nsh.media_player import _async_adb_command_service
@@ -1096,8 +1254,12 @@ class TestAdbCommandService:
class _Call:
def __init__(self, data):
"""A service call as the handler sees it: ``data``, plus the ``hass`` a
device_id needs to resolve through the entity registry (t14)."""
def __init__(self, data, hass=None):
self.data = data
self.hass = hass
class _SetupHass: