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:
+23 -4
View File
@@ -124,12 +124,26 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
"async_forward_entry_setups": ("homeassistant.config_entries", "ConfigEntries"),
"async_unload_platforms": ("homeassistant.config_entries", "ConfigEntries"),
"async_add_executor_job": ("homeassistant.core", "HomeAssistant"),
# t14: THE TARGET-BEARING REGISTRATION. `ServiceRegistry.async_register` takes no
# `target` (measured on 2026.10.1, and the reason the user's UI offered no Shield
# to select): the frontend's picker is rendered from the service DESCRIPTION,
# which `async_set_service_schema` publishes and which WINS over a services.yaml.
# Both names are resolved against the INSTALLED HA at scan time, so an API that
# is renamed or dropped is a finding here, not a log line on a user's install.
"async_register": ("homeassistant.core", "ServiceRegistry"),
# NOTE the owner is the MODULE, not a class: the description writer and the
# device-expansion helper are module-level functions. That matters — the
# device path first shipped as `registry.async_entries_for_device(...)`, which is
# a method that does not exist; the real-HA probe caught it, and this entry makes
# the API's existence a scanned fact instead of a runtime AttributeError.
"async_set_service_schema": ("homeassistant.helpers", "service"),
"async_entries_for_device": ("homeassistant.helpers", "entity_registry"),
}
#: Non-vacuity floors — the ONE source of truth for the standalone entry point
#: and the pytest wrapper (t39 F1). They sit below the current walk (24 modules,
#: 58 imports, 179 relative imports, 46 checked arguments, 7 awaited HA calls,
#: 1102 statement blocks) so a broken or mis-targeted walk fails loudly instead of
#: and the pytest wrapper (t39 F1). They sit below the current walk (25 modules,
#: 73 imports, 193 relative imports, 54 checked arguments, 6 awaited HA calls,
#: 1235 statement blocks) so a broken or mis-targeted walk fails loudly instead of
#: printing zeros and exiting 0.
FLOORS: dict[str, int] = {
# t41: the sweep is recursive, so the floor sits above the top-level-only
@@ -144,7 +158,12 @@ FLOORS: dict[str, int] = {
# carries a real settlement argument is load-bearing (show_form, show_menu,
# abort, create_entry, update_entry). The floor sits two below the count so a
# one-call-site refactor does not redden the gate.
"call_args": 44,
#
# t14: raised from 44 to just under the current 54, because the target-bearing
# registration added two owners (`async_register`'s `schema`/`supports_response`
# arguments and the module-level description writer). At 44 those owners could go
# away without the gate noticing; at 52 they are load-bearing.
"call_args": 52,
"awaited_calls": 5,
# t62 F2: the unreachable-statement pass walks ~1100 statement blocks. A
# floor here means a pass that silently stopped parsing cannot look green.
+372
View File
@@ -0,0 +1,372 @@
"""t14: `nvidia_shield.adb_command` against a REAL Home Assistant install.
The user's report was that the Shield could not be SELECTED for the action, so he
ran his diagnostics through the deprecated ``androidtv`` integration. A stub suite
cannot settle that: the picker is whatever HA hands the browser, and "was the
command sent to the Shield I picked?" is decided by HA's own service registry,
schema validation and target merging.
What this file drives, on the installed HA (2026.10.1):
* the REAL ``hass.services`` registry — the registration our ``async_setup`` makes;
* the REAL websocket command the frontend calls (``get_services``), asserting the
payload carries ``target: {entity: [{domain: media_player}]}`` — that payload IS
the picker;
* the REAL ``hass.services.async_call`` with ``return_response=True`` — asserting
the output comes back under ``adb_response`` (the key the user's captures use)
and that a call without it behaves as before;
* the REAL entity registry, for the ``device_id`` shape an automation sends.
WHAT THIS FILE CANNOT PROVE: there is no browser here, so nothing about how the
picker LOOKS. It proves the wire contract — the registration, the metadata the UI
consumes, and the resolved call. Every test skips cleanly when homeassistant is not
installed (run it with ``--with 'homeassistant>=2026.10' --with adb-shell``).
"""
from __future__ import annotations
import importlib
import json
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
import pytest_asyncio
pytest.importorskip(
"homeassistant",
reason="the real service registry is the point of this file",
)
from homeassistant.components.websocket_api import commands as ws_commands # noqa: E402
from homeassistant.config_entries import ( # noqa: E402
SOURCE_USER,
ConfigEntries,
ConfigEntry,
)
from homeassistant.core import HomeAssistant # noqa: E402
from homeassistant.exceptions import ServiceValidationError # noqa: E402
from homeassistant.helpers import device_registry as device_registry # noqa: E402
from homeassistant.helpers import entity_registry as er # noqa: E402
from homeassistant.helpers.entity_registry import ( # noqa: E402
EntityRegistryItems,
EntityRegistrySettings,
)
from homeassistant.helpers.service import ( # noqa: E402
async_get_all_descriptions,
)
_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
DOMAIN = "nvidia_shield"
SERVICE = "adb_command"
ENTITY_ID = "media_player.shield"
class FakePoller:
"""The ADB surface the handler uses: one shell call, real values back."""
def __init__(self, output="ok", error=None):
self.output = output
self.error = error
self.calls: list[str] = []
async def shell(self, command):
self.calls.append(command)
if self.error is not None:
raise self.error
return self.output
@pytest_asyncio.fixture(name="ha")
async def ha_fixture(tmp_path):
"""A real HomeAssistant with the real registries and the real service registry.
No core bootstrap: the registries are filled directly with the two containers
core's loader fills (same objects), exactly as the t56 flow probe does — so an
EntitySelector / target expansion sees the entities the real one would.
"""
hass = HomeAssistant(str(tmp_path))
device_registry.async_setup(hass)
await device_registry.async_load(hass)
registry = er.async_get(hass)
registry.entities = EntityRegistryItems(hass)
registry.deleted_entities = {}
registry.settings = EntityRegistrySettings()
registry._entities_data = registry.entities.data
hass.data[er.DATA_REGISTRY] = registry
# the real entry manager: device_registry.async_get_or_create needs an entry it
# can look up, exactly as a real device registration does
hass.config_entries = ConfigEntries(hass, {})
hass.config_entries.async_schedule_reload = lambda entry_id: None
entry = ConfigEntry(
version=1,
minor_version=1,
domain=DOMAIN,
title="Shield",
data={},
options={},
source=SOURCE_USER,
unique_id=None,
subentries_data=(),
discovery_keys={},
)
object.__setattr__(entry, "_options", {})
hass.config_entries._entries[entry.entry_id] = entry
hass._shield_entry = entry
return hass
@pytest.fixture(name="integration")
def integration_fixture():
"""The integration package, imported by path under its real name."""
return importlib.import_module("custom_components.nvidia_shield")
@pytest.fixture(name="media_player_module")
def media_player_fixture():
return importlib.import_module("custom_components.nvidia_shield.media_player")
@pytest_asyncio.fixture(name="shield")
async def shield_fixture(ha, integration, media_player_module):
"""Set the integration up, and register ONE Shield as an adb_command target.
Yields the fake manager behind that entity, so a test can see exactly how many
commands reached the device.
"""
await integration.async_setup(ha, {})
poller = FakePoller()
manager = SimpleNamespace(adb_poller=poller, connected=True)
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = manager
try:
yield poller
finally:
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
def _service_payload(descriptions):
return descriptions[DOMAIN][SERVICE]
class RecordingConnection:
"""The websocket connection shape ``handle_get_services`` needs."""
def __init__(self):
self.messages: list[dict] = []
def send_message(self, message):
self.messages.append(message)
def send_result(self, msg_id, payload):
self.messages.append({"id": msg_id, "type": "result", "result": payload})
async def test_the_registration_declares_a_response(ha, integration, shield):
"""t14: a service that answers must say so — and OPTIONAL keeps callers free."""
from homeassistant.core import SupportsResponse
service = ha.services.async_services()[DOMAIN][SERVICE]
assert service.supports_response is SupportsResponse.OPTIONAL
async def test_the_frontend_payload_carries_the_target_picker(ha, integration, shield):
"""THE fix: what the browser receives for this action must offer the Shield.
Driven through the real websocket command (``get_services``), because that is
the payload the UI builds its action form and target picker from.
"""
payload = await async_get_all_descriptions(ha)
description = _service_payload(payload)
# the entity target the picker is rendered from
assert description["target"] == {"entity": [{"domain": ["media_player"]}]}
# a response-bearing action: the UI can offer to return the output
assert description["response"] == {"optional": True}
# both fields survive the runtime description (the form would be empty without)
assert set(description["fields"]) == {"command", "entity_id"}
# ...and it serialises as JSON, which is what actually crosses the wire
assert json.loads(json.dumps(payload))[DOMAIN][SERVICE]["target"] == {
"entity": [{"domain": ["media_player"]}]
}
async def test_the_websocket_command_serves_the_same_payload(ha, integration, shield):
"""The exact command the frontend calls, not just the accessor behind it."""
connection = RecordingConnection()
# the command is decorated: it schedules the handler as a background task and
# returns None, which is exactly how the frontend's request is served
ws_commands.handle_get_services(ha, connection, {"id": 7, "type": "get_services"})
await ha.async_block_till_done()
# HA serialises the response to JSON BYTES before it reaches the socket, so this
# is the exact text the browser parses for the action form
assert len(connection.messages) == 1
message = json.loads(connection.messages[0])
assert message["id"] == 7
assert message["success"] is True
served = message["result"][DOMAIN][SERVICE]
assert served["target"] == {"entity": [{"domain": ["media_player"]}]}
assert served["response"] == {"optional": True}
assert set(served["fields"]) == {"command", "entity_id"}
async def test_a_call_returns_the_command_output(ha, integration, shield):
"""The response the user's captures read: ``adb_response``, the old key."""
response = await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo hi", "entity_id": ENTITY_ID},
blocking=True,
return_response=True,
)
assert response == {"adb_response": "ok"}
assert shield.calls == ["echo hi"]
async def test_a_call_from_the_ui_target_shape_returns_the_output(ha, integration, shield):
"""HA merges a picked target into the call as a LIST — accept it."""
response = await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo list", "entity_id": [ENTITY_ID]},
blocking=True,
return_response=True,
)
assert response == {"adb_response": "ok"}
assert shield.calls == ["echo list"]
async def test_a_call_without_the_response_still_works(ha, integration, shield):
"""Existing YAML callers: same data, no ``return_response`` — unchanged."""
result = await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo old", "entity_id": ENTITY_ID},
blocking=True,
)
assert result is None
assert shield.calls == ["echo old"]
async def test_the_device_target_shape_resolves_through_the_registry(
ha, integration, shield, media_player_module
):
"""An automation that targets the DEVICE sends ``device_id``: resolve it."""
device = device_registry.async_get(ha).async_get_or_create(
config_entry_id=ha._shield_entry.entry_id,
identifiers={(DOMAIN, "shield-device")},
name="Shield",
)
er.async_get(ha).async_get_or_create(
"media_player",
DOMAIN,
"uid_shield",
device_id=device.id,
suggested_object_id="shield",
)
response = await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo device", "device_id": device.id},
blocking=True,
return_response=True,
)
assert response == {"adb_response": "ok"}
assert shield.calls == ["echo device"]
async def test_no_target_is_an_error_naming_what_to_pass(ha, integration, shield):
"""Never a silent no-op: HA's own validation is not the only guard."""
with pytest.raises(ServiceValidationError) as err:
await ha.services.async_call(
DOMAIN, SERVICE, {"command": "echo x"}, blocking=True
)
assert err.value.translation_key == "no_target"
assert shield.calls == []
async def test_two_targets_send_nothing(ha, integration, shield, media_player_module):
"""A raw shell command is never broadcast to Shields the user did not pick."""
other = SimpleNamespace(adb_poller=FakePoller(), connected=True)
media_player_module._ADB_COMMAND_TARGETS["media_player.other"] = other
try:
with pytest.raises(ServiceValidationError) as err:
await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo x", "entity_id": [ENTITY_ID, "media_player.other"]},
blocking=True,
)
assert err.value.translation_key == "ambiguous_target"
assert shield.calls == []
assert other.adb_poller.calls == []
finally:
media_player_module._ADB_COMMAND_TARGETS.pop("media_player.other", None)
async def test_an_unknown_entity_is_an_error(ha, integration, shield):
"""Another integration's media player is not a Shield to run shell on."""
with pytest.raises(ServiceValidationError) as err:
await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo x", "entity_id": "media_player.someone_else"},
blocking=True,
)
assert err.value.translation_key == "unsupported_target"
assert shield.calls == []
async def test_an_unreachable_shield_is_an_error_not_an_empty_response(
ha, integration, shield, media_player_module
):
"""The acceptance's honesty clause: ADB down → error, not ``None`` back."""
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = SimpleNamespace(
adb_poller=None, connected=False
)
try:
with pytest.raises(ServiceValidationError) as err:
await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo x", "entity_id": ENTITY_ID},
blocking=True,
return_response=True,
)
assert err.value.translation_key == "adb_unavailable"
finally:
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
async def test_a_failing_transport_surfaces_the_error(
ha, integration, shield, media_player_module
):
"""A transport failure mid-call propagates — never ``adb_response: None``."""
poller = FakePoller(error=ConnectionError("adb socket dead"))
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = SimpleNamespace(
adb_poller=poller, connected=True
)
try:
with pytest.raises(ConnectionError):
await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo x", "entity_id": ENTITY_ID},
blocking=True,
return_response=True,
)
finally:
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
+121
View File
@@ -0,0 +1,121 @@
"""t14: the adb_command ACTION is selectable — the contract HA reads.
The user's report was that the Shield could not be selected for the
``nvidia_shield.adb_command`` action, so he ran his diagnostics through the
deprecated ``androidtv`` integration instead. The cause was measured against the
installed Home Assistant (2026.10.1), not guessed:
* ``ServiceRegistry.async_register`` takes no ``target`` — a target CANNOT be
declared at registration;
* the frontend's target picker comes from the service DESCRIPTION, which HA builds
from ``services.yaml`` or from ``homeassistant.helpers.service.async_set_service_schema``,
and the runtime description WINS;
* therefore THIS integration must publish a description carrying
``target: {entity: [{domain: media_player}]}`` — that is the picker.
This file pins the published contract itself, without any HA installed: the
description, and the copy in BOTH translation files. The registration and the
resolution are exercised in ``test_command_routing.py::TestAdbCommandService``
(stubs) and in ``tests/test_adb_command_real_ha.py`` (real HA).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
_ROOT = Path(__file__).resolve().parent.parent
_SHIELD = _ROOT / "custom_components" / "nvidia_shield"
@pytest.fixture(name="const")
def const_fixture():
"""The integration's const module, loaded by path like every other test."""
import sys
import conftest
if "nsh.const" not in sys.modules:
conftest._load_module("nsh.const", conftest._SHIELD_DIR / "const.py")
return sys.modules["nsh.const"]
def _strings(name: str) -> dict:
return json.loads((_SHIELD / name).read_text(encoding="utf-8"))
class TestThePublishedTarget:
"""What HA is told the action accepts."""
def test_the_target_is_the_shields_media_player_entity(self, const):
assert const.ADB_COMMAND_SERVICE_TARGET == {
"entity": [{"domain": const.ADB_COMMAND_TARGET_DOMAIN}]
}
assert const.ADB_COMMAND_TARGET_DOMAIN == "media_player"
def test_the_description_carries_the_target_and_the_two_fields(self, const):
description = const.ADB_COMMAND_SERVICE_DESCRIPTION
# the picker: without this key the UI offers no Shield at all
assert description["target"] is const.ADB_COMMAND_SERVICE_TARGET
assert set(description["fields"]) == {"command", "entity_id"}
assert description["fields"]["command"]["required"] is True
# a plain (non-required) field: the TARGET is what selects the Shield, and
# an entity_id given anyway still has to point at a participant
assert "required" not in description["fields"]["entity_id"]
def test_the_response_key_is_the_one_the_old_integration_returned(self, const):
# the user's captures read `adb_response`; keeping the name means a template
# written against `androidtv.adb_command` keeps working
assert const.ADB_COMMAND_RESPONSE_KEY == "adb_response"
class TestTheCopyMatchesBothTranslationFiles:
"""The description's prose is duplicated in the translations — so it cannot drift.
``strings.json``/``translations/en.json`` are where HA's translations live and
the description is what the frontend receives; the two copies must stay
identical, and every raised translation_key needs copy in BOTH files or the user
sees a bare key (t29's lesson, applied to t14's two new keys).
"""
@pytest.mark.parametrize("name", ["strings.json", "translations/en.json"])
def test_the_service_copy_matches_the_description(self, const, name):
service = _strings(name)["services"]["adb_command"]
description = const.ADB_COMMAND_SERVICE_DESCRIPTION
assert service["name"] == description["name"]
assert service["description"] == description["description"]
for field, copy in description["fields"].items():
assert service["fields"][field]["name"] == copy["name"]
assert service["fields"][field]["description"] == copy["description"]
@pytest.mark.parametrize("name", ["strings.json", "translations/en.json"])
def test_the_new_exception_keys_have_copy(self, const, name):
exceptions = _strings(name)["exceptions"]
for key in ("no_target", "ambiguous_target", "unsupported_target"):
assert exceptions[key]["title"], (name, key)
assert exceptions[key]["message"], (name, key)
# the placeholders the handler fills in are the ones the copy names
assert "{entity_id}" in exceptions["ambiguous_target"]["message"]
assert "{entity_id}" in exceptions["unsupported_target"]["message"]
assert "{service}" in exceptions["no_target"]["message"]
def test_both_files_are_identical(self):
# they are two copies of the same English copy — a divergence would mean one
# of them is dead weight
assert (_SHIELD / "strings.json").read_text(
encoding="utf-8"
) == (_SHIELD / "translations" / "en.json").read_text(encoding="utf-8")
def test_the_entity_field_copy_names_the_picker(self):
# the field the YAML caller passes has to say what to pass, now that the
# picker is the primary way in
for name in ("strings.json", "translations/en.json"):
copy = _strings(name)["services"]["adb_command"]["fields"]["entity_id"]
assert "media_player" in copy["description"]
assert "device_id" in copy["description"]
+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:
+7 -1
View File
@@ -79,14 +79,20 @@ class _SeedHass(SimpleNamespace):
config_entries = _SeedEntriesStub()
config_entries.updating_entry_calls = []
self._services = {("nvidia_shield", "adb_command"): None}
#: t14: per-service response support the registration declared.
self.response_support: dict[tuple, object] = {}
super().__init__(
config_entries=config_entries,
scheduled_tasks=[],
services=self,
)
def async_register(self, domain, service, func, schema=None):
def async_register(
self, domain, service, func, schema=None, supports_response=None
):
self._services[(domain, service)] = (func, schema)
#: t14: HA is told whether the action answers with a response.
self.response_support[(domain, service)] = supports_response
def has_service(self, domain, service):
return (domain, service) in self._services