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

+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.