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).
373 lines
13 KiB
Python
373 lines
13 KiB
Python
"""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)
|