Files
ha-nvidia-shield/tests/test_adb_command_real_ha.py
T
James Mackay 6aa3b6bfdd 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).
2026-10-12 11:23:47 +01:00

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)