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:
1 parent
8a4e72b80a
commit
6aa3b6bfdd
13 files changed
+1087
-56
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user