diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 92028a8..ee336a3 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -114,24 +114,34 @@ jobs: - name: HA API-drift sweep run: python3 -m pytest tests/test_ha_api_drift.py -q - # The real-HA config-flow tests (tests/test_config_flow_real_ha.py) are the - # ONLY place the config flow meets the real homeassistant.data_entry_flow - # machinery — and that file SKIPS ITSELF at import time when homeassistant is - # absent (pytest.importorskip at module scope). A skipped file and a passing - # one are indistinguishable in the jobs above: `python3 -m pytest tests -q` - # ends in "N passed, 1 skipped" and still exits 0, and the drift sweep never - # touches this file. That is exactly how three real-HA-only facts stayed - # hidden while five gates were green: + # The real-HA tests are the ONLY place the integration meets the installed Home + # Assistant — and EVERY one of them SKIPS ITSELF at import time when + # homeassistant is absent (pytest.importorskip at module scope). A skipped file + # and a passing one are indistinguishable in the jobs above: `python3 -m pytest + # tests -q` ends in "N passed, 3 skipped" and still exits 0, and the drift sweep + # never touches them. That is exactly how three real-HA-only facts stayed hidden + # while five gates were green: # # * a menu row id must equal its async_step_ method name (otherwise the # real FlowManager raises when the row is dispatched), # * the rendered step_id must equal the row id, and # * async_show_menu takes no errors kwarg. # + # t14 added a second file, and with it the reason this job GLOBS instead of + # naming one: tests/test_adb_command_real_ha.py proves the adb_command action is + # SELECTABLE (the runtime service description carries the entity target the + # frontend builds its picker from — `async_register` has no target parameter) and + # that a real async_call returns the command's output, and it also caught an + # `async_entries_for_device` that is a MODULE-LEVEL helper, not a registry + # method. A named file list would have left it skipping silently here. The + # pattern `tests/test_*real_ha.py` is the rule: a new real-HA file is picked up + # by virtue of existing, and the files that ran are printed. + # # So this job does two things the others cannot: it installs the real stack on # Home Assistant's interpreter floor, and it FAILS LOUDLY if the tests are - # skipped instead of run. Do not "simplify" the guard at the bottom away — a - # test file that skips silently is indistinguishable from one that passes. + # skipped instead of run (or if the pattern matches nothing). Do not "simplify" + # the guard at the bottom away — a test file that skips silently is + # indistinguishable from one that passes. real-ha-config-flow: runs-on: ubuntu-latest steps: @@ -180,40 +190,54 @@ jobs: print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}") PY - - name: Real-HA config-flow tests + - name: Real-HA tests run: | set -euo pipefail - LOG=/tmp/real-ha-config-flow.log + LOG=/tmp/real-ha.log + # THE PATTERN, not a filename: every tests/test_*real_ha.py runs, so a new + # real-HA file cannot skip silently merely because nobody listed it here. + # An empty match is a failure too — but it is checked first so the log says + # why instead of blaming pytest's argument parsing. + shopt -s nullglob + FILES=(tests/test_*real_ha.py) + shopt -u nullglob + if [ "${#FILES[@]}" -eq 0 ]; then + echo "::error::no tests/test_*real_ha.py file exists — this gate would pass vacuously" + exit 1 + fi + echo "real-HA files collected (${#FILES[@]}): ${FILES[*]}" # Verbatim the command this gate is validated with locally; -rs is what # makes a silent skip visible in the log. set +e uvx --python 3.14 --with 'homeassistant>=2026.10' --with adb-shell \ --with pytest-asyncio \ - pytest tests/test_config_flow_real_ha.py -q -rs 2>&1 | tee "$LOG" + pytest "${FILES[@]}" -q -rs 2>&1 | tee "$LOG" rc=${PIPESTATUS[0]} set -e if [ "$rc" -ne 0 ]; then - echo "::error::the real-HA config-flow tests exited $rc" + echo "::error::the real-HA tests exited $rc" exit "$rc" fi # THE GUARD. Some pytest versions exit 0 for a file that skipped # itself, so the exit code above is not enough. The last line of a -q - # run is the counts line ("26 passed in 1.6s" / "1 skipped in - # 0.01s"); it must report PASSED tests and nothing else. + # run is the counts line ("50 passed in 1.6s" / "1 skipped in + # 0.01s" / "no tests ran in 0.01s"); it must report PASSED tests and + # nothing else — a skip, a failure and a zero-collection run all fail + # here, which is what makes the glob safe to widen. summary=$(grep -v '^[[:space:]]*$' "$LOG" | tail -n 1) echo "pytest summary: $summary" case "$summary" in [0-9]*" passed"*) ;; *) - echo "::error::the real-HA config-flow tests did not run (summary: $summary)" - echo "The file skips itself when homeassistant/probatio is missing — a skip here is a FAILURE, not a pass." + echo "::error::the real-HA tests did not run (summary: $summary)" + echo "These files skip themselves when homeassistant is missing — a skip here is a FAILURE, not a pass." exit 1 ;; esac case "$summary" in *skipped*|*failed*|*error*|*"no tests ran"*) - echo "::error::the real-HA config-flow run was not a clean pass (summary: $summary)" + echo "::error::the real-HA run was not a clean pass (summary: $summary)" exit 1 ;; esac diff --git a/README.md b/README.md index 3dd0e55..4cb6e55 100644 --- a/README.md +++ b/README.md @@ -162,14 +162,58 @@ the contractual path (YAML is the seed, the flow is the truth). ### Raw `adb_command` service (opt-in) When enabled in the integration's options (`adb_command_enabled`, **default -off**), the integration registers the `nvidia_shield.adb_command` service: -send a raw ADB shell command to the entry's persistent connection -(`{"command": "your-command", "entity_id": "media_player."}` — the -entity is optional when exactly one entry provides the service). This is the -same security posture as the Home Assistant core `androidtv` integration: a -documented, opt-in escape hatch for debugging and automation, registered at -setup and unregistered when the last participating entity unloads. It -rejects (logs a warning, sends nothing) when the ADB connection is down. +off**), the integration registers the `nvidia_shield.adb_command` service: send a +raw ADB shell command to the entry's persistent connection. + +**Picking the Shield in the UI.** The action declares a target, so **Developer +tools → Actions** (and the automation editor) offers a target picker whose entity +list contains the Shield's `media_player` entity — the same way the core +`androidtv` integration's action worked. Select the Shield there, type the +command, and call it. +Home Assistant renders that picker from the action's *target description*, which +the integration publishes at setup — that is why the Shield is selectable at all. + +**As YAML / automation data**, the same call: + +```yaml +- action: nvidia_shield.adb_command + target: + entity_id: media_player.shield + data: + command: getprop ro.product.model + response_variable: adb # optional: keep the output in a variable +``` + +- `command` (**required**) — the shell command. An empty one is an error + (`invalid_command`), never a silent no-op. +- The target (**required**) is the Shield's `media_player` entity, or its + `device_id` (`target: {device_id: …}` / `entity_id: …`). There is **no + "pick the only one" fallback**: with no target the call fails with `no_target`, + two targets fail with `ambiguous_target`, and an entity that does not participate + (its entry has the option off) fails with `unsupported_target` — a raw shell + command is never sent to a Shield you did not select. +- **The output comes back** in the response under `adb_response` — the same key the +deprecated `androidtv.adb_command` action returned, so a template written against +that integration keeps reading the same field: + +```yaml +- action: nvidia_shield.adb_command + target: {entity_id: media_player.shield} + data: {command: "getprop ro.product.model"} + response_variable: adb +- action: notify.persistent_notification + data: + message: "{{ adb.adb_response }}" +``` + + A caller that does not ask for a response is unchanged (the response is + *optional*, not required). If ADB is down the action raises `adb_unavailable` — + an error, never an empty response; a transport failure mid-command propagates as + an error too. + +This is the same security posture as the Home Assistant core `androidtv` +integration: a documented, opt-in escape hatch for debugging and automation, +registered at setup and unregistered when the last participating entity unloads. ### Screen capture (opt-in, manual) diff --git a/custom_components/nvidia_shield/__init__.py b/custom_components/nvidia_shield/__init__.py index 25d8524..070d2c0 100644 --- a/custom_components/nvidia_shield/__init__.py +++ b/custom_components/nvidia_shield/__init__.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from .adb_session import AdbMediaSessionPoller from .connection import ShieldConnectionManager from .const import ( + ADB_COMMAND_SERVICE_DESCRIPTION, CONF_ADB_ENABLED, CONF_ADB_HOST, CONF_ADB_PORT, @@ -80,14 +81,35 @@ async def async_setup(hass: HomeAssistant, config) -> bool: data=block, ) ) - # t29 G8 (kept here across the t30 splice): the action registers ALWAYS + # t29 G8 (kept here across the t30 splice): the action registers ALWAYS. + # The two APIs the target-bearing registration needs are imported WITH that + # registration, next to the handler they belong to (t14): importing the package — + # which every config-flow harness does via + # ``custom_components.nvidia_shield.config_flow`` — must not drag in the service + # registration surface for a domain that harness never sets up. + from homeassistant.core import SupportsResponse + from homeassistant.helpers.service import async_set_service_schema + from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service + # t14: the action must be SELECTABLE for the user's Shield. Measured on HA + # 2026.10.1: `async_register` has no `target` parameter, so the UI's target + # picker comes from the service DESCRIPTION — the runtime + # `async_set_service_schema` (which wins over a services.yaml we do not ship) + # carries `target: {entity: [{domain: media_player}]}`, so the picker offers the + # Shield's media player entity. `supports_response=OPTIONAL` is registered FIRST + # because async_set_service_schema reads `hass.services.supports_response()` to + # publish the response flag, and the handler returns the command's output under + # ADB_COMMAND_RESPONSE_KEY. hass.services.async_register( DOMAIN, SERVICE_ADB_COMMAND, _async_adb_command_service, schema=_ADB_COMMAND_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + async_set_service_schema( + hass, DOMAIN, SERVICE_ADB_COMMAND, ADB_COMMAND_SERVICE_DESCRIPTION ) return True diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index 73ec0bd..6b284a1 100644 --- a/custom_components/nvidia_shield/const.py +++ b/custom_components/nvidia_shield/const.py @@ -147,11 +147,70 @@ APP_LABEL_GREP_SHAPES = "application-label|nonLocalizedLabel|labelRes" # found anywhere resets the run. APP_LABEL_PROBE_LIMIT = 5 -# P4-2a adb_command service: rawADB shell on this entry — OFF by default +# P4-2a adb_command service: raw ADB shell on this entry — OFF by default # (same security posture as the core androidtv integration). CONF_ADB_COMMAND_ENABLED = "adb_command_enabled" SERVICE_ADB_COMMAND = "adb_command" +#: t14: the key the command's output is returned under, in the service RESPONSE. +#: It matches the deprecated ``androidtv.adb_command`` the user fell back to, so a +#: template written against that integration keeps reading the same field. +ADB_COMMAND_RESPONSE_KEY = "adb_response" + +#: t14: the picker the UI offers for `nvidia_shield.adb_command`. +#: +#: MEASURED on HA 2026.10.1: ``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 instead (``services.yaml``, or a +#: runtime ``async_set_service_schema`` call, which WINS over services.yaml) — this +#: is that description's ``target``. Only the ENTITY kind is declared: the Shield is +#: selected as its ``media_player`` entity (one per entry), exactly like the +#: deprecated ``androidtv.adb_command`` the user used to fall back to. +#: +#: The entity domain the action's target declares, and the only domain a +#: ``device_id`` expands to (a Shield device also carries sensors, a remote, an +#: image entity — none of those is something to run a raw shell command on). +ADB_COMMAND_TARGET_DOMAIN = "media_player" + +ADB_COMMAND_SERVICE_TARGET: dict = { + "entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}] +} + +#: t14: the service DESCRIPTION registered at setup (``async_set_service_schema``). +#: +#: Why a runtime description at all: the description is what the frontend receives +#: from ``get_services``, and a service with no description has no ``target``, which +#: is precisely why the user's UI offered no Shield to select and he had to run his +#: diagnostics through the deprecated integration. The prose here mirrors +#: ``strings.json``/``translations/en.json`` (where HA's translations live) so the +#: form renders even before a translation is loaded; ``tests/test_adb_command_target.py`` +#: asserts the two cannot drift. +ADB_COMMAND_SERVICE_DESCRIPTION: dict = { + "name": "ADB command", + "description": ( + "Send a raw ADB shell command to the persistent connection of an Nvidia " + "Shield entry (option-gated use)." + ), + "target": ADB_COMMAND_SERVICE_TARGET, + "fields": { + "command": { + "name": "Command", + "description": "The shell command to run on the device.", + "required": True, + "selector": {"text": {}}, + }, + "entity_id": { + "name": "Entity", + "description": ( + "The Shield to run it on: its media_player entity. Select it in " + "the target picker above, or pass entity_id (or the Shield's " + "device_id) in YAML." + ), + "selector": {"entity": {"domain": "media_player"}}, + }, + }, +} + # P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer # (foreground AppState fast-path). Default OFF; the user flips it on and # reports stability on their device. diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 9539f72..45558ef 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -81,15 +81,26 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later, async_track_state_change_event from .connection import ShieldConnectionManager from .const import ( + ADB_COMMAND_RESPONSE_KEY, + ADB_COMMAND_TARGET_DOMAIN, ATTR_MEDIA_DAG_JELLYFIN_ENTITY, ATTR_MEDIA_DAG_RECEIVER_ENTITY, ATTR_MEDIA_DAG_RECEIVER_INPUT, @@ -113,6 +124,7 @@ from .const import ( SCRIPT_VAR_APP_NAME, SCRIPT_VAR_LEVEL, SCRIPT_VAR_MUTED, + SERVICE_ADB_COMMAND, TIVIMATE_PACKAGE, command_scripts, ) @@ -1739,35 +1751,126 @@ _ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {} _ADB_COMMAND_SCHEMA = vol.Schema( { vol.Required("command"): str, - vol.Required("entity_id"): str, + # t14: the UI's target picker sends the SELECTED Shield as `entity_id` + # (a string, or a LIST — HA's picker sends a list for a picked target), + # and an automation that targets by device sends `device_id`. Both arrive + # here as text-or-list and are resolved to exactly ONE participant in the + # handler: a command must never be sent to a Shield the user did not pick, + # and "which one?" is never guessed. + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_DEVICE_ID): cv.ensure_list_csv, } ) -async def _async_adb_command_service(call) -> None: - """Run a raw ADB shell command on the target connection (t29 G8). +def _requested_adb_command_targets(call) -> list[str]: + """The entity ids the caller selected, in order, deduplicated (t14). + + TWO shapes reach here, both of them HA's own: the UI's target picker merges the + selection into the call as ``entity_id``, and an automation written against a + device target sends ``device_id``. A value is a string or a list (the picker + sends a list). + + A device_id expands ONLY through this integration's own registry entries, so a + device id can never pull in an unrelated integration's entities — and with no + registry to consult it expands to nothing (an error naming the target, never a + guess). + """ + requested: list[str] = [] + for value in cv.ensure_list(call.data.get(ATTR_ENTITY_ID)): + text = str(value).strip() + if text: + requested.append(text) + device_ids = cv.ensure_list(call.data.get(ATTR_DEVICE_ID)) + if device_ids: + hass = getattr(call, "hass", None) + registry = er.async_get(hass) if hass is not None else None + for device_id in device_ids: + text = str(device_id).strip() + if not text or registry is None: + continue + # MEASURED on HA 2026.10.1: ``async_entries_for_device`` is a + # MODULE-LEVEL helper that takes the registry — the EntityRegistry class + # does not carry the method — and only the real-HA file drives it. + for entry in er.async_entries_for_device(registry, text): + # A Shield DEVICE carries sensors, a remote and an image entity too; + # only the media player is something this action can act on. + if entry.domain == ADB_COMMAND_TARGET_DOMAIN: + requested.append(entry.entity_id) + return list(dict.fromkeys(requested)) + + +def _single_adb_command_target(call) -> str: + """Exactly ONE participating Shield, or a ServiceValidationError (t14). + + The three failures are told apart because the fix differs: nothing selected + (``no_target`` — pick the Shield), something selected that does not participate + (``unsupported_target`` — the entry's option is off, or it is another + integration's entity), and more than one selected (``ambiguous_target`` — a raw + shell command is never broadcast, and never aimed at the wrong Shield). + """ + requested = _requested_adb_command_targets(call) + if not requested: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_target", + translation_placeholders={"service": f"{DOMAIN}.{SERVICE_ADB_COMMAND}"}, + ) + unknown = [ + entity_id for entity_id in requested if entity_id not in _ADB_COMMAND_TARGETS + ] + if unknown: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_target", + translation_placeholders={"entity_id": ", ".join(unknown)}, + ) + if len(requested) > 1: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="ambiguous_target", + translation_placeholders={"entity_id": ", ".join(requested)}, + ) + return requested[0] + + +async def _async_adb_command_service(call): + """Run a raw ADB shell command on the SELECTED Shield (t29 G8, t14). The integration's async_setup registers this ALWAYS; the entry's adb_command_enabled option gates USE (participants register from async_added_to_hass). Failures raise ServiceValidationError with translated keys (action-exceptions) — never warning logs. + + t14 — THE TARGET. The service is registered domain-wide with a runtime + description whose ``target`` is the Shield's ``media_player`` entity, so the UI + offers a picker; HA merges whatever was picked into ``call.data`` as + ``entity_id`` and/or ``device_id`` (a string, or the list a picked target + produces). Resolution is deliberately strict — exactly one participant, or a + ServiceValidationError naming what to pass — because the alternative (picking a + Shield for the user) is a raw shell command sent to the wrong device. + + THE RESPONSE (t14). The output comes back under ``adb_response``, the same key + the deprecated ``androidtv.adb_command`` returned, so a template written + against that integration keeps working. Callers that do not ask for a response + are unaffected: HA drops the value, and the registration is OPTIONAL (never + "only"). An unreachable Shield is an error (``adb_unavailable``), not an empty + response. """ command = str(call.data.get("command") or "").strip() if not command: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_command" ) - entity_id = call.data.get("entity_id") - manager = _ADB_COMMAND_TARGETS.get(entity_id) - if manager is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unsupported_target", - ) + entity_id = _single_adb_command_target(call) + manager = _ADB_COMMAND_TARGETS[entity_id] poller = manager.adb_poller # public accessor (t21 F6) if poller is None or not manager.connected: raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="adb_unavailable" + translation_domain=DOMAIN, + translation_key="adb_unavailable", + translation_placeholders={"entity_id": entity_id}, ) result = await poller.shell(command) _LOGGER.debug("adb_command %r → output=%s", command, result is not None) + return {ADB_COMMAND_RESPONSE_KEY: result} diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index a2e91fe..eeb913a 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -344,7 +344,7 @@ }, "entity_id": { "name": "Entity", - "description": "The Nvidia Shield media player to target." + "description": "The Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML." } } } @@ -369,6 +369,14 @@ "command_script_failed": { "title": "Command script failed", "message": "The script configured for {action} ({script}) failed: {error}" + }, + "no_target": { + "title": "No Shield selected", + "message": "Select the Shield to run this on: the target picker's entity (or pass entity_id / device_id in YAML). {service} sends nothing without a target." + }, + "ambiguous_target": { + "title": "More than one Shield selected", + "message": "{entity_id} covers more than one adb_command target — select exactly one Shield (one media_player entity) — command not sent." } }, "entity": { diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index a2e91fe..eeb913a 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -344,7 +344,7 @@ }, "entity_id": { "name": "Entity", - "description": "The Nvidia Shield media player to target." + "description": "The Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML." } } } @@ -369,6 +369,14 @@ "command_script_failed": { "title": "Command script failed", "message": "The script configured for {action} ({script}) failed: {error}" + }, + "no_target": { + "title": "No Shield selected", + "message": "Select the Shield to run this on: the target picker's entity (or pass entity_id / device_id in YAML). {service} sends nothing without a target." + }, + "ambiguous_target": { + "title": "More than one Shield selected", + "message": "{entity_id} covers more than one adb_command target — select exactly one Shield (one media_player entity) — command not sent." } }, "entity": { diff --git a/tests/entity_stublib.py b/tests/entity_stublib.py index 25df1d6..8aa20ce 100644 --- a/tests/entity_stublib.py +++ b/tests/entity_stublib.py @@ -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: diff --git a/tests/ha_api_drift.py b/tests/ha_api_drift.py index efe01bb..5e0617a 100644 --- a/tests/ha_api_drift.py +++ b/tests/ha_api_drift.py @@ -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. diff --git a/tests/test_adb_command_real_ha.py b/tests/test_adb_command_real_ha.py new file mode 100644 index 0000000..a1d82e1 --- /dev/null +++ b/tests/test_adb_command_real_ha.py @@ -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) diff --git a/tests/test_adb_command_target.py b/tests/test_adb_command_target.py new file mode 100644 index 0000000..179bd7e --- /dev/null +++ b/tests/test_adb_command_target.py @@ -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"] diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index 7afeb6a..4920fd4 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -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: diff --git a/tests/test_init.py b/tests/test_init.py index b44575b..5c8d3a4 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -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