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
@@ -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"]
|
||||
Reference in new issue
Block a user