Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c588a7c66b | ||
|
|
fd62d850b7 | ||
|
|
824d6192a1 | ||
|
|
60fb4f7557 | ||
|
|
bd72ae3170 | ||
|
|
37b2accfda | ||
|
|
5ede4fe722 | ||
|
|
e11dccf2c8 | ||
|
|
7a0152e6b5 | ||
|
|
186e5c1935 | ||
|
|
08b97fde73 | ||
|
|
a6cc56feb4 | ||
|
|
73e886dafe | ||
|
|
bbd98ebc3f | ||
|
|
60d426945b | ||
|
|
c20146b08b | ||
|
|
6087566336 | ||
|
|
8f40b6435c | ||
|
|
c1a82a2fa5 | ||
|
|
782d749b4a | ||
|
|
d004e73d99 | ||
|
|
e77bc12d81 | ||
|
|
80ddff313a | ||
|
|
297f8a21a7 | ||
|
|
a6eeae86e9 | ||
|
|
2e70587871 | ||
|
|
194a97ce34 | ||
|
|
7c12d96070 | ||
|
|
a5254b4740 | ||
|
|
383ec5ada5 | ||
|
|
e1a0fc3039 | ||
|
|
086c089cc1 | ||
|
|
bcb8b962e7 | ||
|
|
3e0208789a |
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
# Quality gate: lint + unit tests.
|
# Quality gate: lint + unit tests + the HA API-drift sweep.
|
||||||
#
|
#
|
||||||
# The test suite is stdlib-only (parsers, intents, running-apps) and runs the
|
# The test suite is stdlib-only (parsers, intents, running-apps) and runs the
|
||||||
# vendored ha_adb_common helpers without installing Home Assistant — see
|
# vendored ha_adb_common helpers without installing Home Assistant — see
|
||||||
@@ -6,6 +6,11 @@
|
|||||||
# volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real
|
# volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real
|
||||||
# firmware captures live in tests/fixtures
|
# firmware captures live in tests/fixtures
|
||||||
# (how to capture more: docs/RESEARCH_SHIELD.md §8).
|
# (how to capture more: docs/RESEARCH_SHIELD.md §8).
|
||||||
|
#
|
||||||
|
# The `ha-api-drift` job is the ONE place Home Assistant is installed: every
|
||||||
|
# other job validates our code against hand-written stubs, which stay green
|
||||||
|
# even when a call site no longer matches the real API. That is how three
|
||||||
|
# drifts reached master (t36 D1/D2/D3) — see tests/ha_api_drift.py.
|
||||||
|
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
@@ -21,10 +26,89 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install test tooling
|
- name: Install test tooling
|
||||||
run: python3 -m pip install --quiet pytest pytest-asyncio ruff androidtvremote2
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Try the plain install first: it succeeds on images whose Python is
|
||||||
|
# not externally managed (and on pip < 23, which has no PEP 668
|
||||||
|
# enforcement at all). When the runner image DOES mark its Python as
|
||||||
|
# externally managed — Debian/Ubuntu based Gitea act images do — pip
|
||||||
|
# refuses with externally-managed-environment and we retry with the
|
||||||
|
# sanctioned override. That override is safe here specifically
|
||||||
|
# because the job runs in a disposable container, and because the
|
||||||
|
# flag only exists on pip >= 23.0.1, the same version that raises
|
||||||
|
# the refusal in the first place.
|
||||||
|
#
|
||||||
|
# Tools are pinned to the versions the suite was validated against so
|
||||||
|
# a newer ruff rule set cannot redden the gate on unrelated commits;
|
||||||
|
# androidtvremote2 tracks the manifest floor so CI exercises the same
|
||||||
|
# dependency users install.
|
||||||
|
PKGS="pytest~=9.1 pytest-asyncio~=1.4 ruff~=0.17.0 'androidtvremote2>=0.2.0'"
|
||||||
|
if ! eval python3 -m pip install --quiet $PKGS; then
|
||||||
|
echo "system Python is externally managed; retrying with --break-system-packages"
|
||||||
|
eval python3 -m pip install --quiet --break-system-packages $PKGS
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: ruff check custom_components tests
|
run: ruff check custom_components tests
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: python3 -m pytest tests -q
|
run: python3 -m pytest tests -q
|
||||||
|
|
||||||
|
# Resolve every Home-Assistant API use in our production modules against a
|
||||||
|
# real install: imports, keyword arguments and await/sync mismatches. The
|
||||||
|
# sweep is repo-owned (tests/ha_api_drift.py) and skips cleanly when
|
||||||
|
# Home Assistant is absent, so it is inert in the job above and live here.
|
||||||
|
ha-api-drift:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Provision the interpreter Home Assistant needs
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
# Home Assistant 2026.10.1 declares Requires-Python >= 3.14.2 (PyPI
|
||||||
|
# metadata; tests/ha_api_drift.py holds our copy of that number and the
|
||||||
|
# drift suite pins it against the installed distribution), so the
|
||||||
|
# runner's own python3 cannot install the version this gate must run
|
||||||
|
# against. This resolves the newest 3.14.x patch; the guard below
|
||||||
|
# refuses anything still short of the floor rather than letting the
|
||||||
|
# install fail halfway through.
|
||||||
|
python-version: "3.14"
|
||||||
|
|
||||||
|
- name: Check the interpreter satisfies Home Assistant's floor
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# The floor comes from tests/ha_api_drift.py — one source of truth for
|
||||||
|
# this guard and the metadata pin in tests/test_ha_api_drift.py.
|
||||||
|
python3 - <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path("tests").resolve()))
|
||||||
|
from ha_api_drift import HOMEASSISTANT_PYTHON_FLOOR as FLOOR # noqa: E402
|
||||||
|
|
||||||
|
needed = ".".join(str(part) for part in FLOOR)
|
||||||
|
if sys.version_info < FLOOR:
|
||||||
|
print(
|
||||||
|
f"Home Assistant needs Python >= {needed}, but this job runs "
|
||||||
|
f"{sys.version.split()[0]}."
|
||||||
|
)
|
||||||
|
print(f"Fix: raise actions/setup-python in this job to a >= {needed} build.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Install Home Assistant + test tooling
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Same PEP 668 dance as the test job (see the note there). The
|
||||||
|
# version floor tracks what we validate against; a resolver failure
|
||||||
|
# here is a real signal (Python too old), not something to swallow.
|
||||||
|
PKGS="pytest~=9.1 pytest-asyncio~=1.4 'homeassistant>=2026.10'"
|
||||||
|
if ! eval python3 -m pip install --quiet $PKGS; then
|
||||||
|
echo "system Python is externally managed; retrying with --break-system-packages"
|
||||||
|
eval python3 -m pip install --quiet --break-system-packages $PKGS
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: HA API-drift sweep
|
||||||
|
run: python3 -m pytest tests/test_ha_api_drift.py -q
|
||||||
@@ -92,6 +92,28 @@ the inter-digit **delay** (0.05–1.0 s, default 0.1) and the optional
|
|||||||
behavior). Both are consumed by `play_media` with
|
behavior). Both are consumed by `play_media` with
|
||||||
`media_content_type: channel`.
|
`media_content_type: channel`.
|
||||||
|
|
||||||
|
### YAML seeding (optional, seed-only)
|
||||||
|
|
||||||
|
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
|
||||||
|
interactive setup flow — nothing is created without the user walking the
|
||||||
|
standard flow, and pairing stays interactive exactly as with UI setup:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nvidia_shield:
|
||||||
|
- name: Living Room
|
||||||
|
adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint
|
||||||
|
adb_port: 5555
|
||||||
|
```
|
||||||
|
|
||||||
|
`protocol_host` is a prefill hint only (the PIN pairing still happens in
|
||||||
|
the flow). Blocks without protocol/adb hosts are ignored with a warning.
|
||||||
|
Duplicate guards: a block matching an existing entry's host (or its
|
||||||
|
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
|
||||||
|
host. Removing the YAML block creates or deletes nothing — entries live
|
||||||
|
in the storage registry and require user consent to remove. Deleting/re-
|
||||||
|
adding is the only way to move an imported entry between hosts; the UI is
|
||||||
|
the contractual path (YAML is the seed, the flow is the truth).
|
||||||
|
|
||||||
### Raw `adb_command` service (opt-in)
|
### Raw `adb_command` service (opt-in)
|
||||||
|
|
||||||
When enabled in the integration's options (`adb_command_enabled`, **default
|
When enabled in the integration's options (`adb_command_enabled`, **default
|
||||||
@@ -160,6 +182,24 @@ device page in HA.
|
|||||||
|
|
||||||
Requirements: Settings > Developer options > Network debugging enabled on the Shield.
|
Requirements: Settings > Developer options > Network debugging enabled on the Shield.
|
||||||
|
|
||||||
|
### Discovery (zeroconf)
|
||||||
|
|
||||||
|
Home Assistant can discover a Shield through the `_androidtvremote2._tcp`
|
||||||
|
service, and the discovered address is pre-filled on the setup form. The dedup
|
||||||
|
rules are Home Assistant's own, and they differ with what the discovery carries:
|
||||||
|
|
||||||
|
- **with** the device MAC, a duplicate aborts as "already configured", and an
|
||||||
|
existing entry's hosts are updated when the address changed;
|
||||||
|
- **without** a MAC, the flow follows HA's stricter
|
||||||
|
`_async_handle_discovery_without_unique_id` contract: **any** existing entry
|
||||||
|
for this integration aborts the discovery, and only one discovery flow runs at
|
||||||
|
a time.
|
||||||
|
|
||||||
|
So a second (or third) Shield that advertises no MAC has to be added **manually**
|
||||||
|
— Settings > Devices & Services > Add Integration > "NVIDIA Shield" — rather than
|
||||||
|
by discovery. That is a deliberate, tested contract (t37 D3), not a bug: the
|
||||||
|
helper the flow calls is a coroutine and its dedup only runs when awaited.
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
- **Jellyfin Media Player** — auto-detected or manually selected
|
- **Jellyfin Media Player** — auto-detected or manually selected
|
||||||
@@ -172,8 +212,8 @@ Requirements: Settings > Developer options > Network debugging enabled on the Sh
|
|||||||
| Entity | Type | Description |
|
| Entity | Type | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| Media Player | `media_player` | Main entity with playback controls and metadata |
|
| Media Player | `media_player` | Main entity with playback controls and metadata |
|
||||||
| Current App | `sensor` | Currently running app |
|
| Current app (`sensor.current_app`) | `sensor` | Currently running app |
|
||||||
| Now Playing | `sensor` | Formatted now-playing string |
|
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string |
|
||||||
| Pair ADB | `button` | Initiate or re-pair ADB connection |
|
| Pair ADB | `button` | Initiate or re-pair ADB connection |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ passive until the user pairs via the Pair ADB button.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -21,10 +22,15 @@ from .adb_session import AdbMediaSessionPoller
|
|||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
CONF_PROTOCOL_ENABLED,
|
CONF_PROTOCOL_ENABLED,
|
||||||
CONF_PROTOCOL_HOST,
|
CONF_PROTOCOL_HOST,
|
||||||
|
DEFAULT_ADB_PORT,
|
||||||
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
)
|
)
|
||||||
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
||||||
|
|
||||||
@@ -33,6 +39,67 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
|
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||||
|
"""Domain setup: actions (always-on) + optional YAML seeding (t30 U1).
|
||||||
|
|
||||||
|
YAML is SEED-ONLY (user ruling): every ``nvidia_shield`` block starts an
|
||||||
|
interactive SOURCE_IMPORT config flow — nothing is created without the
|
||||||
|
user walking the flow, and pairing stays interactive exactly as for UI
|
||||||
|
setup. Removing the YAML block creates OR deletes nothing. UI-configured
|
||||||
|
entries are never re-seeded (host/MAC duplicate guards).
|
||||||
|
"""
|
||||||
|
domain_config = (config or {}).get(DOMAIN)
|
||||||
|
for block in _yaml_seed_blocks(domain_config or []):
|
||||||
|
if not block.get(CONF_ADB_HOST) and not block.get(CONF_PROTOCOL_HOST):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"nvidia_shield YAML block without protocol/adb host ignored: %s",
|
||||||
|
block.get("name", "<unnamed>"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_IMPORT},
|
||||||
|
data=block,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS
|
||||||
|
from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
|
_async_adb_command_service,
|
||||||
|
schema=_ADB_COMMAND_SCHEMA,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _yaml_seed_blocks(domain_config) -> list[dict]:
|
||||||
|
"""Normalize the optional YAML domain key into seed blocks (t30 U1)."""
|
||||||
|
blocks: list[dict] = []
|
||||||
|
raw_list = domain_config if isinstance(domain_config, list) else [domain_config]
|
||||||
|
for block in raw_list:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
name = block.get("name") or DEFAULT_NAME
|
||||||
|
# t32 F4 (review rider): the seed honors the block's shape — a
|
||||||
|
# protocol-only block does NOT force ADB on; ADB comes only when the
|
||||||
|
# block carries an adb host (interactive cosmetics stay in the flow).
|
||||||
|
seed = {
|
||||||
|
"name": name,
|
||||||
|
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
|
||||||
|
CONF_ADB_HOST: str(block.get(CONF_ADB_HOST) or ""),
|
||||||
|
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
|
||||||
|
# protocol fields are prefill hints ONLY — pairing stays interactive
|
||||||
|
CONF_PROTOCOL_HOST: str(block.get(CONF_PROTOCOL_HOST) or ""),
|
||||||
|
}
|
||||||
|
if block.get("unique_id"):
|
||||||
|
seed["unique_id"] = str(block["unique_id"])
|
||||||
|
blocks.append(seed)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
|
async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
"""Poller factory when ADB is configured AND usable today, else None.
|
"""Poller factory when ADB is configured AND usable today, else None.
|
||||||
|
|
||||||
@@ -66,7 +133,18 @@ async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return lambda: AdbMediaSessionPoller(adb.host, adb.port, adb.key_path)
|
# t32 F1 (review repair): the audit-sanctioned FACTORY RE-READ — the
|
||||||
|
# host/port resolve from entry.options AT CALL TIME, so the poller built
|
||||||
|
# after a zeroconf IP change targets the NEW address without a reload
|
||||||
|
# (the setup-time closure pinned the old address). A key-path change
|
||||||
|
# still needs the Pair ADB button / reconfigure reload, by design.
|
||||||
|
def _factory():
|
||||||
|
adb_now = resolve_adb_config(hass, entry)
|
||||||
|
if adb_now is None:
|
||||||
|
return None
|
||||||
|
return AdbMediaSessionPoller(adb_now.host, adb_now.port, adb_now.key_path)
|
||||||
|
|
||||||
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
@@ -94,8 +172,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -164,6 +240,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
||||||
"""Handle options update."""
|
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
|
||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
from homeassistant.components.button import ButtonEntity
|
from homeassistant.components.button import ButtonEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
||||||
@@ -36,6 +36,7 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
|||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_name = "Pair ADB"
|
_attr_name = "Pair ADB"
|
||||||
|
_attr_entity_category = EntityCategory.CONFIG
|
||||||
_attr_icon = "mdi:link-variant"
|
_attr_icon = "mdi:link-variant"
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers import selector
|
from homeassistant.helpers import selector
|
||||||
|
|
||||||
@@ -13,7 +14,6 @@ from .adb_session import ensure_adb_key
|
|||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_MEDIA_DAG_INPUT_MAP,
|
ATTR_MEDIA_DAG_INPUT_MAP,
|
||||||
CHANNEL_DIGIT_DELAY,
|
|
||||||
CONF_ADB_COMMAND_ENABLED,
|
CONF_ADB_COMMAND_ENABLED,
|
||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_HOST,
|
CONF_ADB_HOST,
|
||||||
@@ -32,6 +32,7 @@ from .const import (
|
|||||||
CONF_PROTOCOL_HOST,
|
CONF_PROTOCOL_HOST,
|
||||||
CONF_RECEIVER_ENTITY,
|
CONF_RECEIVER_ENTITY,
|
||||||
CONF_RECEIVER_INPUT,
|
CONF_RECEIVER_INPUT,
|
||||||
|
CONF_SKIP_PAIRING,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -52,6 +53,34 @@ def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
|
|||||||
return list(input_map.keys()) if isinstance(input_map, dict) else []
|
return list(input_map.keys()) if isinstance(input_map, dict) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_id_from_atv_device(hass, androidtv_entity: str) -> str | None:
|
||||||
|
"""A stable unique_id from the atv entity's device MAC (t29 G2).
|
||||||
|
|
||||||
|
The device registry holds CONNECTION_NETWORK_MAC for Android TV
|
||||||
|
entities configured with a host — far stronger than the renameable
|
||||||
|
entity_id. None (→ no unique_id at all, not an entity-derived
|
||||||
|
placeholder) when the entity is unregistered or carries no MAC.
|
||||||
|
"""
|
||||||
|
if not androidtv_entity:
|
||||||
|
return None
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
entity = registry.entities.get(androidtv_entity)
|
||||||
|
device_id = getattr(entity, "device_id", None) if entity else None
|
||||||
|
device = (
|
||||||
|
dr.async_get(hass).async_get(device_id) if device_id else None
|
||||||
|
)
|
||||||
|
if device is None:
|
||||||
|
return None
|
||||||
|
if isinstance(dr, dict): # stub shape guard
|
||||||
|
return None
|
||||||
|
for connection in device.connections:
|
||||||
|
# connections are {connection_type, value} pairs; only a REAL mac
|
||||||
|
# value is usable (an empty placeholder is skipped)
|
||||||
|
if connection[0] == "mac" and connection[1]:
|
||||||
|
return connection[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
||||||
"""Auto-detect the Jellyfin media_player entity for a device."""
|
"""Auto-detect the Jellyfin media_player entity for a device."""
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
@@ -126,12 +155,31 @@ def _app_map_from_text(
|
|||||||
return app_map
|
return app_map
|
||||||
|
|
||||||
|
|
||||||
|
def _host_field_key(field: str, host: str):
|
||||||
|
"""The host-field KEY, carrying the known address as its default.
|
||||||
|
|
||||||
|
t33 F-A: the default MUST ride on this key. Re-declaring a bare
|
||||||
|
``vol.Optional(field)`` afterwards is a no-op that silently drops it —
|
||||||
|
voluptuous keys compare by field name, so the first (defaultless) key
|
||||||
|
object survives and the form renders blank.
|
||||||
|
"""
|
||||||
|
if host:
|
||||||
|
return vol.Optional(field, default=host)
|
||||||
|
return vol.Optional(field)
|
||||||
|
|
||||||
|
|
||||||
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
||||||
"""User-step schema — the androidtv entity is optional (standalone).
|
"""User-step schema — the androidtv entity is optional (standalone).
|
||||||
|
|
||||||
``defaults`` pre-fills name/hosts from a zeroconf discovery event.
|
``defaults`` pre-fills name/hosts from a zeroconf discovery event (or a
|
||||||
|
YAML seed hint); the ADB port is pre-filled with the standard 5555 so it
|
||||||
|
reads as an advanced setting rather than required knowledge (t33 F-D).
|
||||||
"""
|
"""
|
||||||
defaults = defaults or {}
|
defaults = defaults or {}
|
||||||
|
host = str(defaults.get("host") or "")
|
||||||
|
port = defaults.get("port")
|
||||||
|
if not isinstance(port, int):
|
||||||
|
port = DEFAULT_ADB_PORT
|
||||||
schema_dict = {
|
schema_dict = {
|
||||||
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
|
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
|
||||||
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
||||||
@@ -145,15 +193,14 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
|||||||
|
|
||||||
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
||||||
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
|
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||||||
|
# the code's own fallback for this flag is True (async_step_user) — the
|
||||||
|
# rendered checkbox must agree with it
|
||||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
||||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
schema_dict[_host_field_key(CONF_ADB_HOST, host)] = str
|
||||||
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
|
schema_dict[vol.Optional(CONF_ADB_PORT, default=port)] = int
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=False)] = bool
|
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
schema_dict[_host_field_key(CONF_PROTOCOL_HOST, host)] = str
|
||||||
if "host" in defaults:
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_HOST, default=defaults["host"])] = str
|
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST, default=defaults["host"])] = str
|
|
||||||
return schema_dict
|
return schema_dict
|
||||||
|
|
||||||
|
|
||||||
@@ -184,9 +231,14 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self._user_data = {}
|
self._user_data = {}
|
||||||
self._protocol_client = None
|
self._protocol_client = None
|
||||||
self._reauth_entry = None
|
self._reauth_entry = None
|
||||||
|
# audit G5 / t35 F1: the entry a "Reconfigure" visit targets
|
||||||
|
self._reconfigure_entry = None
|
||||||
# Zeroconf discovery state (P4-1)
|
# Zeroconf discovery state (P4-1)
|
||||||
self._discovered_host: str | None = None
|
self._discovered_host: str | None = None
|
||||||
self._discovered_name: str | None = None
|
self._discovered_name: str | None = None
|
||||||
|
# YAML-seed prefill hint (t33 F-A): feeds the same user-step prefill as
|
||||||
|
# a discovery, without claiming the address was discovered.
|
||||||
|
self._seed_host: str | None = None
|
||||||
|
|
||||||
async def async_step_user(self, user_input=None):
|
async def async_step_user(self, user_input=None):
|
||||||
"""Step 1: Basic setup.
|
"""Step 1: Basic setup.
|
||||||
@@ -208,15 +260,20 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
if not has_source:
|
if not has_source:
|
||||||
errors["base"] = "no_state_source"
|
errors["base"] = "no_state_source"
|
||||||
sel = _build_selectors()
|
return self._show_user_form(errors)
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
data_schema=vol.Schema(_user_schema_dict(sel)),
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
if androidtv_entity:
|
# t29 G2: the androidtv entity_id is a renameable reference —
|
||||||
await self.async_set_unique_id(androidtv_entity)
|
# never a unique_id. A sqlite/registry MAC is preferred (the
|
||||||
|
# atv entity's device holds CONNECTION_NETWORK_MAC); without a
|
||||||
|
# resolvable MAC the entry gets NO unique_id (the same
|
||||||
|
# uniqueness-less semantics _async_handle_discovery_without_
|
||||||
|
# unique_id applies to MAC-less discovery) — renames can never
|
||||||
|
# break uniqueness.
|
||||||
|
fallback_unique_id = _unique_id_from_atv_device(
|
||||||
|
self.hass, androidtv_entity
|
||||||
|
)
|
||||||
|
if fallback_unique_id:
|
||||||
|
await self.async_set_unique_id(fallback_unique_id)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
# Auto-detect Jellyfin if not manually specified
|
# Auto-detect Jellyfin if not manually specified
|
||||||
@@ -263,21 +320,48 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return self._create_entry()
|
return self._create_entry()
|
||||||
|
|
||||||
|
return self._show_user_form(errors)
|
||||||
|
|
||||||
sel = _build_selectors()
|
def _user_defaults(self) -> dict:
|
||||||
|
"""Defaults for the user step: whatever setup already knows.
|
||||||
|
|
||||||
defaults = {}
|
A zeroconf discovery (or a YAML seed hint) supplies the address; the
|
||||||
if self._discovered_host:
|
port keeps a seed's hint instead of being flattened to the standard
|
||||||
|
value. This is the ONLY place the known address enters the form — the
|
||||||
|
schema keys carry these values as their defaults (t33 F-A).
|
||||||
|
"""
|
||||||
defaults = {
|
defaults = {
|
||||||
"name": self._discovered_name or DEFAULT_NAME,
|
"name": self._discovered_name
|
||||||
"host": self._discovered_host,
|
or self._user_data.get("name")
|
||||||
|
or DEFAULT_NAME,
|
||||||
}
|
}
|
||||||
schema_dict = _user_schema_dict(sel, defaults)
|
host = self._discovered_host or self._seed_host
|
||||||
|
if host:
|
||||||
|
defaults["host"] = host
|
||||||
|
port = self._user_data.get(CONF_ADB_PORT)
|
||||||
|
if isinstance(port, int):
|
||||||
|
defaults["port"] = port
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
def _show_user_form(self, errors=None):
|
||||||
|
"""Render the user step with the known address actually applied.
|
||||||
|
|
||||||
|
t33 F-A2: a discovery-driven form says so in its description, so a
|
||||||
|
pre-filled host can never be misread as "unknown".
|
||||||
|
"""
|
||||||
|
note = ""
|
||||||
|
if self._discovered_host:
|
||||||
|
note = (
|
||||||
|
f"Discovered Shield at {self._discovered_host} — its address "
|
||||||
|
"is already filled in below. "
|
||||||
|
)
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=vol.Schema(schema_dict),
|
data_schema=vol.Schema(
|
||||||
errors=errors,
|
_user_schema_dict(_build_selectors(), self._user_defaults())
|
||||||
|
),
|
||||||
|
errors=errors or {},
|
||||||
|
description_placeholders={"discovery_note": note},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_zeroconf(self, discovery_info):
|
async def async_step_zeroconf(self, discovery_info):
|
||||||
@@ -323,12 +407,27 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
# host — safe to follow the device address on discovery
|
# host — safe to follow the device address on discovery
|
||||||
options[CONF_ADB_HOST] = host
|
options[CONF_ADB_HOST] = host
|
||||||
if options != entry.options:
|
if options != entry.options:
|
||||||
|
# t29 G3a: the deprecated update+schedule-reload combo is
|
||||||
|
# gone (2026.12 error) — the entry updates ONLY here; the
|
||||||
|
# self-heal rides on the t32 F1 poller factory, which
|
||||||
|
# re-reads entry.options at call time (no reload).
|
||||||
self.hass.config_entries.async_update_entry(
|
self.hass.config_entries.async_update_entry(
|
||||||
entry, options=options
|
entry, options=options
|
||||||
)
|
)
|
||||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
|
||||||
return self.async_abort(reason="already_configured")
|
return self.async_abort(reason="already_configured")
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
else:
|
||||||
|
# t32 F2 / t37 D3: MAC-less discovery cannot carry a unique_id, so
|
||||||
|
# HA's own helper owns the dedup — and it MUST be awaited. It is a
|
||||||
|
# coroutine that aborts `already_configured` when the handler
|
||||||
|
# already has entries, claims DEFAULT_DISCOVERY_UNIQUE_ID, and
|
||||||
|
# aborts `already_in_progress` when another flow for this handler
|
||||||
|
# is running. t32 shipped the call without the await, so none of
|
||||||
|
# that ran ("coroutine was never awaited") and the host-equality
|
||||||
|
# guard that used to sit here was the accidental dedup; the real
|
||||||
|
# helper is stricter (any entry for the domain, any in-flight
|
||||||
|
# flow), so nothing is left to do here.
|
||||||
|
await self._async_handle_discovery_without_unique_id()
|
||||||
|
|
||||||
# First-time setup: pre-filled user step
|
# First-time setup: pre-filled user step
|
||||||
return await self.async_step_user()
|
return await self.async_step_user()
|
||||||
@@ -366,11 +465,21 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
"""Protocol host: explicit override, else the ADB host fallback."""
|
"""Protocol host: explicit override, else the ADB host fallback."""
|
||||||
return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host()
|
return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host()
|
||||||
|
|
||||||
|
def _discard_protocol_client(self) -> None:
|
||||||
|
"""Drop a pairing client whose session became void (host corrected)."""
|
||||||
|
client = self._protocol_client
|
||||||
|
self._protocol_client = None
|
||||||
|
if client is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
async def _start_protocol_pairing(self):
|
async def _start_protocol_pairing(self):
|
||||||
"""Build a throwaway protocol client and make the Shield show its PIN.
|
"""Build a throwaway protocol client and make the Shield show its PIN.
|
||||||
|
|
||||||
Certificates live under ``.storage/<domain>/`` (core androidtv_remote
|
Certificates live under ``.storage/<domain>/`` (core androidtv_remote
|
||||||
helpers.py pattern) and are shared between pairing and runtime.
|
helpers.py pattern) and are shared between pairing and runtime. Returns
|
||||||
|
None when no host is resolvable or ``androidtvremote2`` is missing —
|
||||||
|
the caller turns that into an error key, never a crash.
|
||||||
"""
|
"""
|
||||||
host = self._protocol_host()
|
host = self._protocol_host()
|
||||||
if not host:
|
if not host:
|
||||||
@@ -384,32 +493,116 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
certfile=certfile,
|
certfile=certfile,
|
||||||
keyfile=keyfile,
|
keyfile=keyfile,
|
||||||
)
|
)
|
||||||
|
if client is None:
|
||||||
|
# build_protocol_client already logged the missing library;
|
||||||
|
# pairing cannot start, and the form still offers the skip path
|
||||||
|
return None
|
||||||
await client.async_generate_cert_if_missing()
|
await client.async_generate_cert_if_missing()
|
||||||
await client.async_start_pairing()
|
await client.async_start_pairing()
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
async def _async_begin_protocol_pairing(self):
|
||||||
|
"""Start a PIN pairing session: ``(client, None)`` or ``(None, error)``."""
|
||||||
|
if not self._protocol_host():
|
||||||
|
return None, "protocol_no_host"
|
||||||
|
client = await self._start_protocol_pairing()
|
||||||
|
if client is None:
|
||||||
|
return None, "protocol_unavailable"
|
||||||
|
return client, None
|
||||||
|
|
||||||
|
def _show_protocol_pair_form(self, errors=None):
|
||||||
|
"""protocol_pair form: the PIN, the address, and the skip escape.
|
||||||
|
|
||||||
|
t33 F-B/F-C: the host field lives on THIS form, so an unresolvable
|
||||||
|
address is fixable where the error is shown (the user step is gone),
|
||||||
|
and the skip option keeps the step from being a dead end.
|
||||||
|
"""
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="protocol_pair",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_PAIRING_CODE): str,
|
||||||
|
_host_field_key(CONF_PROTOCOL_HOST, self._protocol_host() or ""): str,
|
||||||
|
vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors=errors or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_skip_protocol_pairing(self):
|
||||||
|
"""Continue without the protocol transport (t33 F-B, passive mode).
|
||||||
|
|
||||||
|
The transport is switched OFF rather than left half-paired: an
|
||||||
|
unpaired protocol client fails auth on its first connect and would
|
||||||
|
only bounce the user into the reauth flow — a fresh dead end.
|
||||||
|
"""
|
||||||
|
self._discard_protocol_client()
|
||||||
|
self._user_data[CONF_PROTOCOL_ENABLED] = False
|
||||||
|
if self._reauth_entry is not None:
|
||||||
|
# an existing entry cannot be re-created: leave it untouched and
|
||||||
|
# say so instead of pretending the re-pair worked
|
||||||
|
self._reauth_entry = None
|
||||||
|
return self.async_abort(reason="reauth_skipped")
|
||||||
|
if self._reconfigure_entry is not None:
|
||||||
|
# same for a Reconfigure visit: nothing changed, the entry keeps
|
||||||
|
# its options and can be reconfigured again at any time
|
||||||
|
self._reconfigure_entry = None
|
||||||
|
return self.async_abort(reason="reconfigure_skipped")
|
||||||
|
return self._create_entry()
|
||||||
|
|
||||||
|
async def _async_restart_protocol_pairing(self, errors: dict):
|
||||||
|
"""Start a fresh pairing session and render the form for its code.
|
||||||
|
|
||||||
|
Called on the first visit, after a host correction, and when the user
|
||||||
|
submits without a code: each is a request for the Shield to show a new
|
||||||
|
code, and none of them may leave the step stuck.
|
||||||
|
"""
|
||||||
|
self._discard_protocol_client()
|
||||||
|
client, error = await self._async_begin_protocol_pairing()
|
||||||
|
if error:
|
||||||
|
errors["base"] = error
|
||||||
|
else:
|
||||||
|
self._protocol_client = client
|
||||||
|
return self._show_protocol_pair_form(errors)
|
||||||
|
|
||||||
async def async_step_protocol_pair(self, user_input=None):
|
async def async_step_protocol_pair(self, user_input=None):
|
||||||
"""Pair with the remote protocol — the PIN is displayed on the Shield."""
|
"""Pair with the remote protocol — the PIN is displayed on the Shield.
|
||||||
|
|
||||||
|
Never a dead end (t33 F-B): a corrected address restarts pairing right
|
||||||
|
here, submitting without a code asks for a fresh one, and
|
||||||
|
``skip_pairing`` finishes the entry in passive mode.
|
||||||
|
"""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
# First visit: proactively start pairing so the PIN shows on the
|
# First visit: proactively start pairing so the PIN shows on the
|
||||||
# Shield while the form is open.
|
# Shield while the form is open.
|
||||||
client = await self._start_protocol_pairing()
|
return await self._async_restart_protocol_pairing(errors)
|
||||||
if client is None:
|
|
||||||
errors["base"] = "protocol_no_host"
|
if user_input.get(CONF_SKIP_PAIRING):
|
||||||
else:
|
return await self._async_skip_protocol_pairing()
|
||||||
self._protocol_client = client
|
|
||||||
else:
|
typed_host = str(user_input.get(CONF_PROTOCOL_HOST) or "").strip()
|
||||||
|
if typed_host and typed_host != (self._protocol_host() or ""):
|
||||||
|
# A host typed HERE is authoritative — the user step is gone, so
|
||||||
|
# the session started against the old address is dropped and the
|
||||||
|
# Shield is asked for a NEW code at the corrected address (t35 F1).
|
||||||
|
self._user_data[CONF_PROTOCOL_HOST] = typed_host
|
||||||
|
self._discard_protocol_client()
|
||||||
|
|
||||||
|
code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip()
|
||||||
|
if self._protocol_client is None or not code:
|
||||||
|
# No live session, or no code typed: (re)start pairing so the
|
||||||
|
# Shield shows a code for the address currently on the form.
|
||||||
|
return await self._async_restart_protocol_pairing(errors)
|
||||||
|
|
||||||
client = self._protocol_client
|
client = self._protocol_client
|
||||||
code = user_input.get(CONF_PAIRING_CODE, "")
|
|
||||||
if client is None or not code:
|
|
||||||
errors["base"] = "protocol_invalid_code"
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
await client.async_finish_pairing(code)
|
await client.async_finish_pairing(code)
|
||||||
except Exception:
|
except Exception:
|
||||||
errors["base"] = "protocol_pair_failed"
|
errors["base"] = "protocol_pair_failed"
|
||||||
else:
|
return self._show_protocol_pair_form(errors)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_name, mac = await client.async_get_name_and_mac()
|
_name, mac = await client.async_get_name_and_mac()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -417,19 +610,73 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
client.disconnect()
|
client.disconnect()
|
||||||
self._protocol_client = None
|
self._protocol_client = None
|
||||||
if mac:
|
target = self._reauth_entry or self._reconfigure_entry
|
||||||
|
if mac and mac != getattr(target, "unique_id", None):
|
||||||
|
# A MAC that is already configured must never be adopted silently.
|
||||||
|
# A reauth/reconfigure flow already OWNS an entry, and
|
||||||
|
# _abort_if_unique_id_configured looks the domain up by unique_id —
|
||||||
|
# it would abort on that very entry when the MAC is unchanged
|
||||||
|
# (t35 F1: re-pairing an entry used to abort as "already_configured").
|
||||||
await self.async_set_unique_id(mac)
|
await self.async_set_unique_id(mac)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
if self._reauth_entry is not None:
|
if self._reauth_entry is not None:
|
||||||
return self._finish_reauth()
|
return self._finish_reauth()
|
||||||
|
if self._reconfigure_entry is not None:
|
||||||
|
return self._finish_reconfigure(mac)
|
||||||
return self._create_entry()
|
return self._create_entry()
|
||||||
return self.async_show_form(
|
|
||||||
step_id="protocol_pair",
|
async def async_step_import(self, data):
|
||||||
data_schema=vol.Schema({
|
"""Seed an interactive flow from YAML (t30 U1, seed-only ruling).
|
||||||
vol.Required(CONF_PAIRING_CODE): str,
|
|
||||||
}),
|
The import validates the block and routes into the standard user
|
||||||
errors=errors,
|
flow — pairing stays interactive, exactly one visit. Duplicate
|
||||||
|
guards: the block's host/MAC-virtual unique_id aborts when an
|
||||||
|
entry already holds it, and a UI-configured entry with the same
|
||||||
|
protocol/adb host is never re-seeded.
|
||||||
|
"""
|
||||||
|
block = dict(data or {})
|
||||||
|
name = block.get("name") or DEFAULT_NAME
|
||||||
|
seed_unique_id = block.get("unique_id")
|
||||||
|
if not seed_unique_id:
|
||||||
|
host = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
|
||||||
|
seed_unique_id = f"yaml:{name}:{host or 'nohost'}"
|
||||||
|
|
||||||
|
# duplicate guards
|
||||||
|
yhost = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
|
||||||
|
for existing in self._async_current_entries():
|
||||||
|
ehost = existing.options.get(CONF_PROTOCOL_HOST) or existing.options.get(
|
||||||
|
CONF_ADB_HOST
|
||||||
)
|
)
|
||||||
|
if existing.unique_id == seed_unique_id or (
|
||||||
|
yhost and ehost == yhost
|
||||||
|
):
|
||||||
|
_LOGGER.info(
|
||||||
|
"nvidia_shield YAML seed for %r matches an existing "
|
||||||
|
"entry — not re-seeded",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
return self.async_abort(reason="already_configured")
|
||||||
|
await self.async_set_unique_id(seed_unique_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
# route into the standard user flow (single interactive visit);
|
||||||
|
# t32 F4: the prefill honors the block shape — ADB only when the
|
||||||
|
# block actually carries an adb host.
|
||||||
|
self._user_data = {
|
||||||
|
"name": name,
|
||||||
|
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
|
||||||
|
CONF_ADB_HOST: block.get(CONF_ADB_HOST, ""),
|
||||||
|
CONF_ADB_PORT: block.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
|
# protocol prefill hints ONLY (pairing stays interactive)
|
||||||
|
CONF_PROTOCOL_HOST: block.get(CONF_PROTOCOL_HOST, ""),
|
||||||
|
}
|
||||||
|
# t33 F-A: the seed host feeds the same user-step prefill as a
|
||||||
|
# zeroconf discovery, but WITHOUT the discovery announcement — the
|
||||||
|
# address came from configuration.yaml, not from the network.
|
||||||
|
self._seed_host = self._user_data[CONF_PROTOCOL_HOST] or self._user_data[
|
||||||
|
CONF_ADB_HOST
|
||||||
|
]
|
||||||
|
return await self.async_step_user(None)
|
||||||
|
|
||||||
async def async_step_reauth(self, entry_data):
|
async def async_step_reauth(self, entry_data):
|
||||||
"""Device hard reset invalidated our certificates (review trigger)."""
|
"""Device hard reset invalidated our certificates (review trigger)."""
|
||||||
@@ -456,10 +703,66 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
step_id="reauth_confirm", data_schema=vol.Schema({})
|
step_id="reauth_confirm", data_schema=vol.Schema({})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_reconfigure(self, user_input=None):
|
||||||
|
"""Re-run protocol pairing for an existing entry (audit G5, t35 F1).
|
||||||
|
|
||||||
|
Home Assistant offers this as the entry's "Reconfigure" action — the
|
||||||
|
discoverable way back for an entry whose protocol transport is off
|
||||||
|
(typically the "continue without protocol pairing" outcome): the
|
||||||
|
address is editable, the Shield shows a PIN, and the entry's options
|
||||||
|
are updated + reloaded.
|
||||||
|
"""
|
||||||
|
entry = self._reconfigure_entry or self._get_reconfigure_entry()
|
||||||
|
if entry is None:
|
||||||
|
# HA always links the entry; a missing one gets a plain abort
|
||||||
|
# instead of an exception
|
||||||
|
return self.async_abort(reason="reconfigure_entry_missing")
|
||||||
|
self._reconfigure_entry = entry
|
||||||
|
# Seed the flow from the entry so the pairing step offers the address
|
||||||
|
# the entry already knows (and _create_entry is never reached here).
|
||||||
|
self._user_data = {
|
||||||
|
"name": entry.title,
|
||||||
|
CONF_ANDROIDTV_ENTITY: entry.data.get(CONF_ANDROIDTV_ENTITY, ""),
|
||||||
|
CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
|
||||||
|
CONF_ADB_HOST: entry.options.get(CONF_ADB_HOST, ""),
|
||||||
|
CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
|
||||||
|
}
|
||||||
|
return await self.async_step_protocol_pair()
|
||||||
|
|
||||||
|
def _finish_reconfigure(self, mac: str | None):
|
||||||
|
"""Reconfigure pairing done: update + reload + abort (G3a contract).
|
||||||
|
|
||||||
|
The protocol host persists as the address that just paired, so the
|
||||||
|
entry no longer depends on the ADB-host fallback for its transport.
|
||||||
|
HA derives the abort reason ("reconfigure_successful") from the flow
|
||||||
|
source, so this needs no integration string.
|
||||||
|
"""
|
||||||
|
entry = self._reconfigure_entry or self._get_reconfigure_entry()
|
||||||
|
updates: dict = {
|
||||||
|
"options": {
|
||||||
|
**entry.options,
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_PROTOCOL_HOST: self._protocol_host() or "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if mac:
|
||||||
|
# a swapped device keeps the entry's identity honest
|
||||||
|
updates["unique_id"] = mac
|
||||||
|
self._reconfigure_entry = None
|
||||||
|
return self.async_update_reload_and_abort(entry, **updates)
|
||||||
|
|
||||||
def _finish_reauth(self):
|
def _finish_reauth(self):
|
||||||
"""Reauth pairing done: update options, schedule the reload, abort."""
|
"""Reauth pairing done: update + reload + abort via the current API (G3a).
|
||||||
entry = self._reauth_entry
|
|
||||||
self.hass.config_entries.async_update_entry(
|
The deprecated update-entry + schedule-reload boomerang listener is
|
||||||
|
gone (2026.12 treats it as an error); the current config-entries
|
||||||
|
contract is async_update_reload_and_abort with _get_reauth_entry.
|
||||||
|
Core's kwarg is `options` (REPLACE) — there is no `options_updates`.
|
||||||
|
"""
|
||||||
|
entry = self._reauth_entry or self._get_reauth_entry()
|
||||||
|
self.async_update_reload_and_abort(
|
||||||
entry,
|
entry,
|
||||||
options={
|
options={
|
||||||
**entry.options,
|
**entry.options,
|
||||||
@@ -467,7 +770,6 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
|
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
|
||||||
self._reauth_entry = None
|
self._reauth_entry = None
|
||||||
return self.async_abort(reason="reauth_successful")
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
@@ -477,14 +779,61 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY)
|
self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _show_adb_pair_form(self, errors=None):
|
||||||
|
"""adb_pair form: the address/port (fixable here) and the skip escape.
|
||||||
|
|
||||||
|
t33 F-B/F-C/F-D: the fields live on THIS form — the user step is gone
|
||||||
|
— so a missing address is fixable where the error appears, the
|
||||||
|
standard 5555 is pre-filled, and the skip option ends the dead end.
|
||||||
|
"""
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="adb_pair",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
_host_field_key(CONF_ADB_HOST, self._resolve_adb_host() or ""): str,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
default=self._user_data.get(
|
||||||
|
CONF_ADB_PORT, DEFAULT_ADB_PORT
|
||||||
|
),
|
||||||
|
): int,
|
||||||
|
vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors=errors or {},
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_adb_pair(self, user_input=None):
|
async def async_step_adb_pair(self, user_input=None):
|
||||||
"""Step 2: ADB pairing. User clicks Submit after approving on the Shield."""
|
"""Step 2: ADB pairing. Approve the prompt on the Shield, then Submit.
|
||||||
|
|
||||||
|
Never a dead end (t33 F-B): the host/port are editable on the form, a
|
||||||
|
missing address is reported against the field above it, and the skip
|
||||||
|
option finishes the entry in passive mode.
|
||||||
|
"""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
host = self._resolve_adb_host()
|
if user_input.get(CONF_SKIP_PAIRING):
|
||||||
port = self._user_data.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
# Continue WITHOUT pairing. ADB stays enabled on purpose: the
|
||||||
|
# manager already degrades to its passive, background-retrying
|
||||||
|
# state when no key exists, and the Pair ADB button (only
|
||||||
|
# registered for adb-enabled entries) pairs later without
|
||||||
|
# re-adding the integration.
|
||||||
|
return await self._async_after_pairing()
|
||||||
|
|
||||||
|
typed_host = str(user_input.get(CONF_ADB_HOST) or "").strip()
|
||||||
|
if typed_host:
|
||||||
|
# a host typed HERE is authoritative (the user step is gone)
|
||||||
|
self._user_data[CONF_ADB_HOST] = typed_host
|
||||||
|
|
||||||
|
port = user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||||
|
try:
|
||||||
|
port = int(port)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
port = DEFAULT_ADB_PORT
|
||||||
|
self._user_data[CONF_ADB_PORT] = port
|
||||||
|
|
||||||
|
host = self._resolve_adb_host()
|
||||||
if not host:
|
if not host:
|
||||||
errors["base"] = "adb_no_host"
|
errors["base"] = "adb_no_host"
|
||||||
else:
|
else:
|
||||||
@@ -500,24 +849,35 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
else:
|
else:
|
||||||
errors["base"] = "adb_failed"
|
errors["base"] = "adb_failed"
|
||||||
|
|
||||||
return self.async_show_form(
|
return self._show_adb_pair_form(errors)
|
||||||
step_id="adb_pair",
|
|
||||||
data_schema=vol.Schema({}),
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(config_entry):
|
def async_get_options_flow(config_entry):
|
||||||
return NvidiaShieldOptionsFlow(config_entry)
|
return NvidiaShieldOptionsFlow()
|
||||||
|
|
||||||
|
|
||||||
class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||||
"""Handle options for Nvidia Shield — single unified page."""
|
"""Handle options for Nvidia Shield — single unified page (t29 G3b).
|
||||||
|
|
||||||
def __init__(self, config_entry):
|
The constructor takes NO arguments: HA assigns ``self.config_entry``
|
||||||
self._config_entry = config_entry
|
on instantiation (the pre-2026 "OptionsFlow(config_entry)" pattern is
|
||||||
self._options = dict(config_entry.options)
|
deprecated). Suggested values come through
|
||||||
|
``add_suggested_values_to_schema``, and saving reloads via the
|
||||||
|
OptionsFlowWithReload base — no listener boomerangs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._options: dict | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _options_dict(self) -> dict:
|
||||||
|
if self._options is None:
|
||||||
|
if self.config_entry is not None:
|
||||||
|
self._options = dict(self.config_entry.options)
|
||||||
|
else:
|
||||||
|
self._options = {}
|
||||||
|
return self._options
|
||||||
|
|
||||||
async def async_step_init(self, user_input=None):
|
async def async_step_init(self, user_input=None):
|
||||||
"""Single-page options."""
|
"""Single-page options."""
|
||||||
@@ -536,7 +896,7 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
||||||
icons: dict[str, str] = {}
|
icons: dict[str, str] = {}
|
||||||
if app_map_text is None:
|
if app_map_text is None:
|
||||||
app_map = self._options.get(CONF_APP_NAME_MAP, {})
|
app_map = self._options_dict.get(CONF_APP_NAME_MAP, {})
|
||||||
elif app_map_text.strip() == "":
|
elif app_map_text.strip() == "":
|
||||||
app_map = {}
|
app_map = {}
|
||||||
else:
|
else:
|
||||||
@@ -545,14 +905,14 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
errors["base"] = "invalid_app_map"
|
errors["base"] = "invalid_app_map"
|
||||||
if not errors:
|
if not errors:
|
||||||
if delay is not None:
|
if delay is not None:
|
||||||
self._options[CONF_CHANNEL_DIGIT_DELAY] = delay
|
self._options_dict[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||||||
self._options.update(user_input)
|
self._options_dict.update(user_input)
|
||||||
self._options[CONF_APP_NAME_MAP] = app_map
|
self._options_dict[CONF_APP_NAME_MAP] = app_map
|
||||||
# P5-2: the icon column — the editor is the source of
|
# P5-2: the icon column — the editor is the source of
|
||||||
# truth, so a save without icons clears them (and a
|
# truth, so a save without icons clears them (and a
|
||||||
# single-column map stays valid).
|
# single-column map stays valid).
|
||||||
self._options[CONF_APP_ICON_MAP] = icons
|
self._options_dict[CONF_APP_ICON_MAP] = icons
|
||||||
return self.async_create_entry(title="", data=self._options)
|
return self.async_create_entry(title="", data=self._options_dict)
|
||||||
|
|
||||||
sel = _build_selectors()
|
sel = _build_selectors()
|
||||||
schema_dict = {}
|
schema_dict = {}
|
||||||
@@ -560,46 +920,36 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
# Apps name/icon map editor (P4-1, core androidtv_remote options
|
# Apps name/icon map editor (P4-1, core androidtv_remote options
|
||||||
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
|
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
|
||||||
# the optional icon column).
|
# the optional icon column).
|
||||||
current_map = self._options.get(CONF_APP_NAME_MAP, {})
|
|
||||||
current_icons = self._options.get(CONF_APP_ICON_MAP, {})
|
|
||||||
if current_map:
|
|
||||||
app_map_default = _app_map_to_text(current_map, current_icons)
|
|
||||||
schema_dict[vol.Optional(CONF_APP_NAME_MAP, default=app_map_default)] = str
|
|
||||||
else:
|
|
||||||
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
|
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
|
||||||
|
|
||||||
# Jellyfin
|
# Jellyfin
|
||||||
jf = self._options.get(CONF_JELLYFIN_ENTITY)
|
jf = self._options_dict.get(CONF_JELLYFIN_ENTITY)
|
||||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||||
if jf:
|
if jf:
|
||||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY, default=jf)
|
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||||
schema_dict[jf_key] = sel["media_player"]
|
schema_dict[jf_key] = sel["media_player"]
|
||||||
|
|
||||||
# Receiver
|
# Receiver
|
||||||
recv = self._options.get(CONF_RECEIVER_ENTITY)
|
recv = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||||
if recv:
|
if recv:
|
||||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv)
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||||
schema_dict[recv_key] = sel["receiver"]
|
schema_dict[recv_key] = sel["receiver"]
|
||||||
|
|
||||||
# P5-2: channel digit behavior (the user-validated §7 flow)
|
# P5-2: channel digit behavior (the user-validated §7 flow)
|
||||||
current_delay = self._options.get(CONF_CHANNEL_DIGIT_DELAY, CHANNEL_DIGIT_DELAY)
|
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
|
||||||
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY, default=current_delay)] = str
|
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
||||||
current_enter = self._options.get(CONF_CHANNEL_SEND_ENTER, False)
|
|
||||||
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER, default=current_enter)] = bool
|
|
||||||
|
|
||||||
# P5-3: the live events push is opt-in (default off; the user
|
# P5-3: the live events push is opt-in (default off; the user
|
||||||
# tests stability on their device and reports)
|
# tests stability on their device and reports)
|
||||||
current_logcat = self._options.get(CONF_LOGCAT_EVENTS, False)
|
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool
|
||||||
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS, default=current_logcat)] = bool
|
|
||||||
|
|
||||||
# P4-2a: the raw adb_command service is opt-in (default off)
|
# P4-2a: the raw adb_command service is opt-in (default off)
|
||||||
current_flag = self._options.get(CONF_ADB_COMMAND_ENABLED, False)
|
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = bool
|
||||||
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED, default=current_flag)] = bool
|
|
||||||
|
|
||||||
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
||||||
current_input = self._options.get(CONF_RECEIVER_INPUT, "")
|
current_input = self._options_dict.get(CONF_RECEIVER_INPUT, "")
|
||||||
receiver = self._options.get(CONF_RECEIVER_ENTITY)
|
receiver = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||||
inputs = _get_receiver_inputs(self.hass, receiver)
|
inputs = _get_receiver_inputs(self.hass, receiver)
|
||||||
|
|
||||||
if inputs:
|
if inputs:
|
||||||
@@ -612,43 +962,32 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
)
|
)
|
||||||
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
||||||
if current_input:
|
if current_input:
|
||||||
input_key = vol.Optional(CONF_RECEIVER_INPUT, default=current_input)
|
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
||||||
schema_dict[input_key] = input_sel
|
schema_dict[input_key] = input_sel
|
||||||
else:
|
else:
|
||||||
schema_dict[vol.Optional(
|
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||||||
CONF_RECEIVER_INPUT, default=current_input
|
|
||||||
)] = str
|
|
||||||
|
|
||||||
# Power
|
# Power
|
||||||
pwr = self._options.get(CONF_POWER_ENTITY)
|
pwr = self._options_dict.get(CONF_POWER_ENTITY)
|
||||||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||||
if pwr:
|
if pwr:
|
||||||
pwr_key = vol.Optional(CONF_POWER_ENTITY, default=pwr)
|
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||||
schema_dict[pwr_key] = sel["power"]
|
schema_dict[pwr_key] = sel["power"]
|
||||||
|
|
||||||
# ADB options
|
# ADB options
|
||||||
schema_dict[vol.Optional(
|
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||||||
CONF_ADB_ENABLED,
|
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
||||||
default=self._options.get(CONF_ADB_ENABLED, False),
|
|
||||||
)] = bool
|
|
||||||
schema_dict[vol.Optional(
|
|
||||||
CONF_ADB_STORE_APP_INFO,
|
|
||||||
default=self._options.get(CONF_ADB_STORE_APP_INFO, True),
|
|
||||||
)] = bool
|
|
||||||
|
|
||||||
# ADB host/port (optional overrides, shown when ADB is enabled)
|
# ADB host/port (optional overrides, shown when ADB is enabled)
|
||||||
adb_host = self._options.get(CONF_ADB_HOST, "")
|
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||||
schema_dict[vol.Optional(
|
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
||||||
CONF_ADB_HOST,
|
|
||||||
default=adb_host,
|
|
||||||
)] = str
|
|
||||||
schema_dict[vol.Optional(
|
|
||||||
CONF_ADB_PORT,
|
|
||||||
default=self._options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
|
||||||
)] = int
|
|
||||||
|
|
||||||
|
# t29 G3b: suggested values come from the entry options via the
|
||||||
|
# documented helper — no per-key `default=` in the schema build.
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=vol.Schema(schema_dict),
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
|
vol.Schema(schema_dict), self._options_dict
|
||||||
|
),
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
@@ -215,6 +215,9 @@ class ShieldConnectionManager:
|
|||||||
self._logcat_disabled = False
|
self._logcat_disabled = False
|
||||||
self._logcat_failures: list[float] = []
|
self._logcat_failures: list[float] = []
|
||||||
self._logcat_last_pkg: str | None = None
|
self._logcat_last_pkg: str | None = None
|
||||||
|
# G10 (t30): protocol device identity captured once per session —
|
||||||
|
# (name, manufacturer, model, sw) for the registry's device card.
|
||||||
|
self.protocol_device_data: tuple[str, str, str, str] | None = None
|
||||||
self._logcat_last_stamp: float = -1e9
|
self._logcat_last_stamp: float = -1e9
|
||||||
# Injectable ladder sleep (t26: deterministic tests / future alt-floors)
|
# Injectable ladder sleep (t26: deterministic tests / future alt-floors)
|
||||||
self._logcat_ladder_sleep = asyncio.sleep
|
self._logcat_ladder_sleep = asyncio.sleep
|
||||||
@@ -565,6 +568,24 @@ class ShieldConnectionManager:
|
|||||||
self._app_label_requests.add(package)
|
self._app_label_requests.add(package)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def async_capture_protocol_device_data(self) -> None:
|
||||||
|
"""Capture (name, manufacturer, model, sw) once (t30 G10)."""
|
||||||
|
if self._protocol is None or self.protocol_device_data is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
info = self._protocol.device_info or {} # {manufacturer, model, sw_version}
|
||||||
|
name, _mac = await asyncio.wait_for(
|
||||||
|
self._protocol.async_get_name_and_mac(), timeout=10.0
|
||||||
|
)
|
||||||
|
self.protocol_device_data = (
|
||||||
|
name,
|
||||||
|
info.get("manufacturer", "Google"),
|
||||||
|
info.get("model", "Shield TV"),
|
||||||
|
info.get("sw_version", ""),
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.debug("protocol device info unavailable: %s", err)
|
||||||
|
|
||||||
async def _fetch_app_label(self, package: str) -> None:
|
async def _fetch_app_label(self, package: str) -> None:
|
||||||
"""One `pm dump` label fetch; failure falls through silently."""
|
"""One `pm dump` label fetch; failure falls through silently."""
|
||||||
poller = self._poller
|
poller = self._poller
|
||||||
@@ -997,6 +1018,14 @@ class ShieldConnectionManager:
|
|||||||
foreground = await poller.poll_foreground()
|
foreground = await poller.poll_foreground()
|
||||||
if foreground:
|
if foreground:
|
||||||
self.store.set(FIELD_FOREGROUND, foreground)
|
self.store.set(FIELD_FOREGROUND, foreground)
|
||||||
|
# G10: capture the protocol device identity for the registry card
|
||||||
|
if self._protocol is not None:
|
||||||
|
self._tasks.append(
|
||||||
|
self._create_task(
|
||||||
|
self.async_capture_protocol_device_data(),
|
||||||
|
"protocol_device_capture",
|
||||||
|
)
|
||||||
|
)
|
||||||
# P5-1: manifest labels resolve on demand, one package per cycle
|
# P5-1: manifest labels resolve on demand, one package per cycle
|
||||||
self._drain_app_label_requests()
|
self._drain_app_label_requests()
|
||||||
# P5-3: ensure the (optional, default-off) logcat fast-path runs
|
# P5-3: ensure the (optional, default-off) logcat fast-path runs
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ CONF_ADB_PORT = "adb_port"
|
|||||||
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
||||||
CONF_PROTOCOL_HOST = "protocol_host"
|
CONF_PROTOCOL_HOST = "protocol_host"
|
||||||
CONF_PAIRING_CODE = "pairing_code"
|
CONF_PAIRING_CODE = "pairing_code"
|
||||||
|
# t33 F-B: the "continue without pairing" escape offered on BOTH pairing
|
||||||
|
# steps — the entry still finishes (passive mode) instead of stranding the
|
||||||
|
# user on a form with no way forward.
|
||||||
|
CONF_SKIP_PAIRING = "skip_pairing"
|
||||||
DEFAULT_PROTOCOL_API_PORT = 6466
|
DEFAULT_PROTOCOL_API_PORT = 6466
|
||||||
DEFAULT_PROTOCOL_PAIR_PORT = 6467
|
DEFAULT_PROTOCOL_PAIR_PORT = 6467
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,30 @@ from dataclasses import asdict, is_dataclass
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.redact import async_redact_data
|
||||||
|
|
||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
|
|
||||||
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
||||||
_REDACTED = "**REDACTED**"
|
_REDACTED = "**REDACTED**"
|
||||||
|
|
||||||
|
# G14: the documented explicit redaction field list — the audit calls out
|
||||||
|
# config fields like the hosts; pairing/key-shaped fields are masked by
|
||||||
|
# async_redact_data and the marker scan adds a second layer.
|
||||||
|
_TO_REDACT = {
|
||||||
|
"pairing_code",
|
||||||
|
"adb_pairing_code",
|
||||||
|
"protocol_pairing_code",
|
||||||
|
"serial_number",
|
||||||
|
"uuid",
|
||||||
|
"adb_host",
|
||||||
|
"protocol_host",
|
||||||
|
"host",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _redact(obj):
|
def _redact(obj):
|
||||||
|
"""Marker-based defensive pass (kept behind the async_redact_data helper)."""
|
||||||
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
|
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return {
|
return {
|
||||||
@@ -72,7 +88,13 @@ async def async_get_config_entry_diagnostics(
|
|||||||
"is_on": manager.is_on,
|
"is_on": manager.is_on,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return _redact(payload)
|
# G14 (t30) + t37 D2: the documented helper is the primary redaction path
|
||||||
|
# with an explicit field list (the hosts included), and the marker scan
|
||||||
|
# above runs defensively after it. It is imported at MODULE scope from
|
||||||
|
# homeassistant.helpers.redact — the real module; the old
|
||||||
|
# `homeassistant.util.diagnostic` never existed, and the swallowed
|
||||||
|
# ImportError turned the whole pass into a silent no-op.
|
||||||
|
return _redact(async_redact_data(payload, _TO_REDACT))
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["async_get_config_entry_diagnostics"]
|
__all__ = ["async_get_config_entry_diagnostics"]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -16,5 +16,10 @@
|
|||||||
"version": "2026.10.08",
|
"version": "2026.10.08",
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
"_androidtvremote2._tcp.local."
|
"_androidtvremote2._tcp.local."
|
||||||
|
],
|
||||||
|
"integration_type": "device",
|
||||||
|
"loggers": [
|
||||||
|
"adb_shell",
|
||||||
|
"androidtvremote2"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -26,14 +26,18 @@ import time
|
|||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
|
BrowseMedia,
|
||||||
|
MediaClass,
|
||||||
MediaPlayerDeviceClass,
|
MediaPlayerDeviceClass,
|
||||||
MediaPlayerEntity,
|
MediaPlayerEntity,
|
||||||
MediaPlayerEntityFeature,
|
MediaPlayerEntityFeature,
|
||||||
MediaPlayerState,
|
MediaPlayerState,
|
||||||
|
MediaType,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||||
@@ -62,7 +66,6 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
PROTOCOL_MEDIA_KEYS,
|
PROTOCOL_MEDIA_KEYS,
|
||||||
PROTOCOL_REMOTE_KEYS,
|
PROTOCOL_REMOTE_KEYS,
|
||||||
SERVICE_ADB_COMMAND,
|
|
||||||
TIVIMATE_PACKAGE,
|
TIVIMATE_PACKAGE,
|
||||||
)
|
)
|
||||||
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
||||||
@@ -216,6 +219,22 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
|
# G10 (t30): when the protocol transport is live and captured, the
|
||||||
|
# registry card carries the protocol-pushed identity (name from the
|
||||||
|
# device itself, manufacturer 'Google' per the audit ruling); the
|
||||||
|
# pre-capture shape stays the entry-title fallback.
|
||||||
|
captured = (
|
||||||
|
self._manager.protocol_device_data if self._manager is not None else None
|
||||||
|
)
|
||||||
|
if captured:
|
||||||
|
name, manufacturer, model, sw = captured
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||||
|
name=name or self._entry.title,
|
||||||
|
manufacturer=manufacturer,
|
||||||
|
model=model,
|
||||||
|
sw_version=sw or None,
|
||||||
|
)
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||||
name=self._entry.title,
|
name=self._entry.title,
|
||||||
@@ -243,7 +262,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
getattr(self, "entity_id", None) or self._attr_unique_id
|
getattr(self, "entity_id", None) or self._attr_unique_id
|
||||||
)
|
)
|
||||||
_ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager
|
_ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager
|
||||||
_adb_command_service_ensure(self.hass)
|
|
||||||
|
|
||||||
# Discover power sensor siblings from the power switch
|
# Discover power sensor siblings from the power switch
|
||||||
if self._power_entity:
|
if self._power_entity:
|
||||||
@@ -280,13 +298,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
for task in self._entity_tasks:
|
for task in self._entity_tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
self._entity_tasks.clear()
|
self._entity_tasks.clear()
|
||||||
# P4-2a: drop this entity's service participation (same key as add)
|
# P4-2a/t29 G8: drop this entity's service participation (same key
|
||||||
if (
|
# as add). The service itself stays registered (always-on setup).
|
||||||
(key := getattr(self, "_adb_command_key", None)) is not None
|
|
||||||
and _ADB_COMMAND_TARGETS.pop(key, None) is not None
|
|
||||||
and self.hass
|
|
||||||
):
|
|
||||||
_adb_command_service_maybe_remove(self.hass)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Intent management ──
|
# ── Intent management ──
|
||||||
@@ -800,7 +813,18 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
last_known=self._manager.last_known_map(),
|
last_known=self._manager.last_known_map(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
# G12 (t30): attributes that change with every poll must not bloat the
|
||||||
|
# database (entity doc) — dag internals are declared unrecorded.
|
||||||
|
_unrecorded_attributes = frozenset(
|
||||||
|
{
|
||||||
|
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||||
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||||
|
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||||
|
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||||
|
ATTR_MEDIA_DAG_ROLE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
# Now playing: prefer Jellyfin, fall back to ADB media session,
|
# Now playing: prefer Jellyfin, fall back to ADB media session,
|
||||||
# then the foreground tier (P3-3a).
|
# then the foreground tier (P3-3a).
|
||||||
@@ -940,29 +964,37 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
# ── BrowseMedia app directory (P4-2c) ──
|
# ── BrowseMedia app directory (P4-2c) ──
|
||||||
|
|
||||||
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
||||||
"""Root lists the apps map (or the curated default) → play_media(app)."""
|
"""Root lists the apps map (or the curated default) → play_media(app).
|
||||||
from homeassistant.components.media_player import (
|
|
||||||
BrowseMediaSource,
|
|
||||||
MediaClass,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
t37 D1: the standard media_player tree, exactly as core's own
|
||||||
|
androidtv_remote/media_player.py builds it — ``BrowseMedia`` from
|
||||||
|
homeassistant.components.media_player, every node carrying an explicit
|
||||||
|
media_content_id/media_content_type, and the directory naming the
|
||||||
|
class of its children. ``BrowseMediaSource`` only ever existed in
|
||||||
|
homeassistant.components.media_source.models (t30 G18's domain
|
||||||
|
parameter is ITS contract: it builds ``media_source://<domain>`` ids),
|
||||||
|
so an app-directory node must never claim one — and the import above
|
||||||
|
removed a name media_player has not exported for a long time, which
|
||||||
|
made every real browse raise.
|
||||||
|
"""
|
||||||
app_map = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
app_map = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
||||||
children = [
|
children = [
|
||||||
BrowseMediaSource(
|
BrowseMedia(
|
||||||
title=name,
|
title=name,
|
||||||
media_class=MediaClass.APP,
|
media_class=MediaClass.APP,
|
||||||
media_content_type="app",
|
media_content_type=MediaType.APP,
|
||||||
media_content_id=pkg,
|
media_content_id=pkg,
|
||||||
can_play=True,
|
can_play=True,
|
||||||
can_expand=False,
|
can_expand=False,
|
||||||
)
|
)
|
||||||
for pkg, name in app_map.items()
|
for pkg, name in app_map.items()
|
||||||
]
|
]
|
||||||
return BrowseMediaSource(
|
return BrowseMedia(
|
||||||
title="Apps",
|
title="Apps",
|
||||||
media_class=MediaClass.DIRECTORY,
|
media_class=MediaClass.DIRECTORY,
|
||||||
media_content_type="apps",
|
media_content_type=MediaType.APPS,
|
||||||
media_content_id="apps",
|
media_content_id="apps",
|
||||||
|
children_media_class=MediaClass.APP,
|
||||||
can_play=False,
|
can_play=False,
|
||||||
can_expand=True,
|
can_expand=True,
|
||||||
children=children,
|
children=children,
|
||||||
@@ -1076,53 +1108,35 @@ _ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {}
|
|||||||
_ADB_COMMAND_SCHEMA = vol.Schema(
|
_ADB_COMMAND_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("command"): str,
|
vol.Required("command"): str,
|
||||||
vol.Optional("entity_id"): str,
|
vol.Required("entity_id"): str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _async_adb_command_service(call) -> None:
|
async def _async_adb_command_service(call) -> None:
|
||||||
"""Run a raw ADB shell command on the targeted entry's connection."""
|
"""Run a raw ADB shell command on the target connection (t29 G8).
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
command = str(call.data.get("command") or "").strip()
|
command = str(call.data.get("command") or "").strip()
|
||||||
if not command:
|
if not command:
|
||||||
_LOGGER.warning("adb_command: empty command ignored")
|
raise ServiceValidationError(
|
||||||
return
|
translation_domain=DOMAIN, translation_key="invalid_command"
|
||||||
entity_id = call.data.get("entity_id")
|
|
||||||
pairs = [
|
|
||||||
(eid, manager)
|
|
||||||
for eid, manager in _ADB_COMMAND_TARGETS.items()
|
|
||||||
if entity_id in (None, eid)
|
|
||||||
]
|
|
||||||
if not pairs:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"adb_command: no participating entry matches %s (is the option "
|
|
||||||
"enabled on the entry's options?)",
|
|
||||||
entity_id,
|
|
||||||
)
|
)
|
||||||
return
|
entity_id = call.data.get("entity_id")
|
||||||
for _eid, manager in pairs:
|
manager = _ADB_COMMAND_TARGETS.get(entity_id)
|
||||||
poller = manager._poller
|
if manager is None:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="unsupported_target",
|
||||||
|
)
|
||||||
|
poller = manager.adb_poller # public accessor (t21 F6)
|
||||||
if poller is None or not manager.connected:
|
if poller is None or not manager.connected:
|
||||||
_LOGGER.warning("adb_command: ADB unavailable — command not sent")
|
raise ServiceValidationError(
|
||||||
continue
|
translation_domain=DOMAIN, translation_key="adb_unavailable"
|
||||||
|
)
|
||||||
result = await poller.shell(command)
|
result = await poller.shell(command)
|
||||||
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
|
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
|
||||||
|
|
||||||
|
|
||||||
def _adb_command_service_ensure(hass) -> None:
|
|
||||||
"""Register the domain service once the first participant appears."""
|
|
||||||
if not hass.services.has_service(DOMAIN, SERVICE_ADB_COMMAND):
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_ADB_COMMAND,
|
|
||||||
_async_adb_command_service,
|
|
||||||
schema=_ADB_COMMAND_SCHEMA,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _adb_command_service_maybe_remove(hass) -> None:
|
|
||||||
"""Unregister when the last participant unloads."""
|
|
||||||
if not _ADB_COMMAND_TARGETS and hass.services.has_service(
|
|
||||||
DOMAIN, SERVICE_ADB_COMMAND
|
|
||||||
):
|
|
||||||
hass.services.async_remove(DOMAIN, SERVICE_ADB_COMMAND)
|
|
||||||
@@ -39,6 +39,8 @@ class NvidiaShieldSensorBase(SensorEntity):
|
|||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
|
# G12 (t30): poll-driven attributes never belong in the database
|
||||||
|
_unrecorded_attributes = frozenset({"app_id", "app_name", "playback_state"})
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
@@ -68,7 +70,7 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
(most recent visible ADB recent-task app).
|
(most recent visible ADB recent-task app).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_attr_name = "Current App"
|
_attr_translation_key = "current_app"
|
||||||
_attr_icon = "mdi:application"
|
_attr_icon = "mdi:application"
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
@@ -136,7 +138,7 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||||
"""What's currently playing via Jellyfin."""
|
"""What's currently playing via Jellyfin."""
|
||||||
|
|
||||||
_attr_name = "Now Playing"
|
_attr_translation_key = "now_playing"
|
||||||
_attr_icon = "mdi:play-circle"
|
_attr_icon = "mdi:play-circle"
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
adb_command:
|
||||||
|
# Both fields required — integrating targeting must not be optional
|
||||||
|
# (docs/dev_101_services: no default target when none specified).
|
||||||
|
name: ADB command
|
||||||
|
description: Send a raw ADB shell command to the persistent connection of
|
||||||
|
an Nvidia Shield entry (the entry must have the adb_command_enabled
|
||||||
|
option on; the integration gates USE, not registration).
|
||||||
|
fields:
|
||||||
|
command:
|
||||||
|
name: Command
|
||||||
|
description: The shell command to run on the device, e.g.
|
||||||
|
"dumpsys power | head -5".
|
||||||
|
required: true
|
||||||
|
example: "dumpsys power | head -5"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
entity_id:
|
||||||
|
name: Entity
|
||||||
|
description: The Nvidia Shield media player entity to target.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
entity:
|
||||||
|
integration: nvidia_shield
|
||||||
|
domain: media_player
|
||||||
@@ -3,104 +3,169 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "NVIDIA Shield",
|
"title": "NVIDIA Shield",
|
||||||
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, the Nvidia Remote Protocol, or ADB. Link the devices and services you want to use.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Device Name",
|
"name": "Name",
|
||||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
|
"androidtv_entity": "Android TV Remote entity",
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver input",
|
||||||
"power_entity": "Power Switch",
|
"power_entity": "Power switch",
|
||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Read state over ADB",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"adb_store_app_info": "Show title and artist",
|
||||||
"adb_host": "ADB Host",
|
"adb_host": "ADB host (IP address)",
|
||||||
"adb_port": "ADB Port",
|
"adb_port": "ADB port",
|
||||||
"protocol_enabled": "Pair with Nvidia Remote Protocol",
|
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||||
"protocol_host": "Protocol Host"
|
"protocol_host": "Protocol host (IP address)"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.",
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
|
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||||
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||||
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity \u2014 pre-filled automatically when the Shield was discovered.",
|
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \u2014 change it only if you need a different address.",
|
||||||
"adb_port": "ADB port number. Default is 5555.",
|
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
|
||||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.",
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step.",
|
||||||
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host) \u2014 pre-filled automatically on discovery."
|
"protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adb_pair": {
|
"adb_pair": {
|
||||||
"title": "Pair ADB",
|
"title": "Allow ADB access",
|
||||||
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
|
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
||||||
|
"data": {
|
||||||
|
"adb_host": "ADB host (IP address)",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"skip_pairing": "Continue without ADB pairing"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
|
||||||
|
"adb_port": "The standard ADB port is 5555.",
|
||||||
|
"skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"protocol_pair": {
|
"protocol_pair": {
|
||||||
"title": "Pair Remote Protocol",
|
"title": "Pair the remote protocol",
|
||||||
"description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.",
|
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
|
||||||
"data": {
|
"data": {
|
||||||
"pairing_code": "Pairing code shown on the Shield"
|
"pairing_code": "Pairing code shown on the Shield",
|
||||||
|
"protocol_host": "Protocol host (IP address)",
|
||||||
|
"skip_pairing": "Continue without protocol pairing"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"pairing_code": "The code the Shield displays while this step is open.",
|
||||||
|
"protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
|
||||||
|
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
"title": "Re-pair Nvidia Shield",
|
"title": "Re-pair Nvidia Shield",
|
||||||
"description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the Shield."
|
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again \u2014 the Shield then shows a code."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
|
"adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
|
||||||
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
|
"adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||||
"protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.",
|
"protocol_no_host": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||||
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.",
|
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
|
||||||
"protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again.",
|
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
|
||||||
"no_state_source": "No state source selected. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host).",
|
"no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, 'Add the Nvidia Remote Protocol', or 'Read state over ADB'.",
|
||||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 e.g. ar.tvplayer.tv=TiviMate.",
|
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.",
|
||||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "This Shield entity is already configured.",
|
"already_configured": "This Shield is already set up.",
|
||||||
"reauth_successful": "Re-paired successfully.",
|
"reauth_successful": "Re-paired successfully.",
|
||||||
|
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
|
||||||
"reauth_entry_missing": "This entry no longer exists.",
|
"reauth_entry_missing": "This entry no longer exists.",
|
||||||
"zeroconf_no_host": "The discovery event carried no usable address."
|
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
|
||||||
|
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
|
||||||
|
"zeroconf_no_host": "The discovery event carried no usable address.",
|
||||||
|
"yaml_seed_only": {},
|
||||||
|
"yaml_seed": {
|
||||||
|
"title": "YAML succeeded earlier",
|
||||||
|
"message": "The configuration.yaml nvidia_shield block is seed-only: it starts the standard interactive flow. Removing the YAML block creates or deletes nothing."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "NVIDIA Shield Settings",
|
"title": "NVIDIA Shield settings",
|
||||||
"description": "Configure linked devices and monitoring.",
|
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
|
||||||
"data": {
|
"data": {
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver input",
|
||||||
"power_entity": "Power Switch",
|
"power_entity": "Power switch",
|
||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Read state over ADB",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"adb_store_app_info": "Show title and artist",
|
||||||
"adb_host": "ADB Host",
|
"adb_host": "ADB host (IP address)",
|
||||||
"adb_port": "ADB Port",
|
"adb_port": "ADB port",
|
||||||
"app_name_map": "Apps name/icon map",
|
"app_name_map": "App names and icons",
|
||||||
"adb_command_enabled": "Enable raw ADB shell service",
|
"adb_command_enabled": "Enable the adb_command service",
|
||||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||||
"channel_send_enter": "Send ENTER after channel digits",
|
"channel_send_enter": "Send ENTER after channel digits",
|
||||||
"logcat_events": "Live events push (logcat)"
|
"logcat_events": "Instant app detection (logcat stream)"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.",
|
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||||
"receiver_input": "Which input on the receiver.",
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||||
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||||
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
"adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.",
|
||||||
"adb_port": "ADB port (default 5555).",
|
"adb_port": "The ADB port. The standard value is 5555.",
|
||||||
"app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear.",
|
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
|
||||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.",
|
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.",
|
||||||
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).",
|
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).",
|
||||||
"channel_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.",
|
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
||||||
"logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device."
|
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"adb_command": {
|
||||||
|
"name": "ADB command",
|
||||||
|
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
|
||||||
|
"fields": {
|
||||||
|
"command": {
|
||||||
|
"name": "Command",
|
||||||
|
"description": "The shell command to run on the device."
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "Entity",
|
||||||
|
"description": "The Nvidia Shield media player to target."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"invalid_command": {
|
||||||
|
"title": "Empty command",
|
||||||
|
"message": "The command field is empty \u2014 nothing was sent."
|
||||||
|
},
|
||||||
|
"unsupported_target": {
|
||||||
|
"title": "Unsupported entity for adb_command",
|
||||||
|
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
|
||||||
|
},
|
||||||
|
"adb_unavailable": {
|
||||||
|
"title": "ADB unavailable",
|
||||||
|
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"current_app": {
|
||||||
|
"name": "Current app"
|
||||||
|
},
|
||||||
|
"now_playing": {
|
||||||
|
"name": "Now playing"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,104 +3,169 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "NVIDIA Shield",
|
"title": "NVIDIA Shield",
|
||||||
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, the Nvidia Remote Protocol, or ADB. Link the devices and services you want to use.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Device Name",
|
"name": "Name",
|
||||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
|
"androidtv_entity": "Android TV Remote entity",
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver input",
|
||||||
"power_entity": "Power Switch",
|
"power_entity": "Power switch",
|
||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Read state over ADB",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"adb_store_app_info": "Show title and artist",
|
||||||
"adb_host": "ADB Host",
|
"adb_host": "ADB host (IP address)",
|
||||||
"adb_port": "ADB Port",
|
"adb_port": "ADB port",
|
||||||
"protocol_enabled": "Pair with Nvidia Remote Protocol",
|
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||||
"protocol_host": "Protocol Host"
|
"protocol_host": "Protocol host (IP address)"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.",
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
|
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||||
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||||
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity \u2014 pre-filled automatically when the Shield was discovered.",
|
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \u2014 change it only if you need a different address.",
|
||||||
"adb_port": "ADB port number. Default is 5555.",
|
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
|
||||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.",
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step.",
|
||||||
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host) \u2014 pre-filled automatically on discovery."
|
"protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adb_pair": {
|
"adb_pair": {
|
||||||
"title": "Pair ADB",
|
"title": "Allow ADB access",
|
||||||
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
|
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
||||||
|
"data": {
|
||||||
|
"adb_host": "ADB host (IP address)",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"skip_pairing": "Continue without ADB pairing"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
|
||||||
|
"adb_port": "The standard ADB port is 5555.",
|
||||||
|
"skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"protocol_pair": {
|
"protocol_pair": {
|
||||||
"title": "Pair Remote Protocol",
|
"title": "Pair the remote protocol",
|
||||||
"description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.",
|
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
|
||||||
"data": {
|
"data": {
|
||||||
"pairing_code": "Pairing code shown on the Shield"
|
"pairing_code": "Pairing code shown on the Shield",
|
||||||
|
"protocol_host": "Protocol host (IP address)",
|
||||||
|
"skip_pairing": "Continue without protocol pairing"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"pairing_code": "The code the Shield displays while this step is open.",
|
||||||
|
"protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
|
||||||
|
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
"title": "Re-pair Nvidia Shield",
|
"title": "Re-pair Nvidia Shield",
|
||||||
"description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the Shield."
|
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again \u2014 the Shield then shows a code."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
|
"adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
|
||||||
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
|
"adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||||
"protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.",
|
"protocol_no_host": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||||
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.",
|
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
|
||||||
"protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again.",
|
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
|
||||||
"no_state_source": "No state source selected. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host).",
|
"no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, 'Add the Nvidia Remote Protocol', or 'Read state over ADB'.",
|
||||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 e.g. ar.tvplayer.tv=TiviMate.",
|
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.",
|
||||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "This Shield entity is already configured.",
|
"already_configured": "This Shield is already set up.",
|
||||||
"reauth_successful": "Re-paired successfully.",
|
"reauth_successful": "Re-paired successfully.",
|
||||||
|
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
|
||||||
"reauth_entry_missing": "This entry no longer exists.",
|
"reauth_entry_missing": "This entry no longer exists.",
|
||||||
"zeroconf_no_host": "The discovery event carried no usable address."
|
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
|
||||||
|
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
|
||||||
|
"zeroconf_no_host": "The discovery event carried no usable address.",
|
||||||
|
"yaml_seed_only": {},
|
||||||
|
"yaml_seed": {
|
||||||
|
"title": "YAML succeeded earlier",
|
||||||
|
"message": "The configuration.yaml nvidia_shield block is seed-only: it starts the standard interactive flow. Removing the YAML block creates or deletes nothing."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "NVIDIA Shield Settings",
|
"title": "NVIDIA Shield settings",
|
||||||
"description": "Configure linked devices and monitoring.",
|
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
|
||||||
"data": {
|
"data": {
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver input",
|
||||||
"power_entity": "Power Switch",
|
"power_entity": "Power switch",
|
||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Read state over ADB",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"adb_store_app_info": "Show title and artist",
|
||||||
"adb_host": "ADB Host",
|
"adb_host": "ADB host (IP address)",
|
||||||
"adb_port": "ADB Port",
|
"adb_port": "ADB port",
|
||||||
"app_name_map": "Apps name/icon map",
|
"app_name_map": "App names and icons",
|
||||||
"adb_command_enabled": "Enable raw ADB shell service",
|
"adb_command_enabled": "Enable the adb_command service",
|
||||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||||
"channel_send_enter": "Send ENTER after channel digits",
|
"channel_send_enter": "Send ENTER after channel digits",
|
||||||
"logcat_events": "Live events push (logcat)"
|
"logcat_events": "Instant app detection (logcat stream)"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.",
|
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||||
"receiver_input": "Which input on the receiver.",
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||||
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||||
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
"adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.",
|
||||||
"adb_port": "ADB port (default 5555).",
|
"adb_port": "The ADB port. The standard value is 5555.",
|
||||||
"app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear.",
|
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
|
||||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.",
|
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.",
|
||||||
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).",
|
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).",
|
||||||
"channel_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.",
|
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
||||||
"logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device."
|
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"adb_command": {
|
||||||
|
"name": "ADB command",
|
||||||
|
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
|
||||||
|
"fields": {
|
||||||
|
"command": {
|
||||||
|
"name": "Command",
|
||||||
|
"description": "The shell command to run on the device."
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "Entity",
|
||||||
|
"description": "The Nvidia Shield media player to target."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"invalid_command": {
|
||||||
|
"title": "Empty command",
|
||||||
|
"message": "The command field is empty \u2014 nothing was sent."
|
||||||
|
},
|
||||||
|
"unsupported_target": {
|
||||||
|
"title": "Unsupported entity for adb_command",
|
||||||
|
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
|
||||||
|
},
|
||||||
|
"adb_unavailable": {
|
||||||
|
"title": "ADB unavailable",
|
||||||
|
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"current_app": {
|
||||||
|
"name": "Current app"
|
||||||
|
},
|
||||||
|
"now_playing": {
|
||||||
|
"name": "Now playing"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+283
-17
@@ -10,11 +10,15 @@ import asyncio
|
|||||||
import enum
|
import enum
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
import voluptuous_mirror
|
||||||
|
|
||||||
ENTITIES_STUB_NAMES = (
|
ENTITIES_STUB_NAMES = (
|
||||||
"voluptuous",
|
"voluptuous",
|
||||||
"homeassistant",
|
"homeassistant",
|
||||||
"homeassistant.config_entries",
|
"homeassistant.config_entries",
|
||||||
|
"homeassistant.exceptions",
|
||||||
"homeassistant.core",
|
"homeassistant.core",
|
||||||
"homeassistant.const",
|
"homeassistant.const",
|
||||||
"homeassistant.components",
|
"homeassistant.components",
|
||||||
@@ -23,8 +27,10 @@ ENTITIES_STUB_NAMES = (
|
|||||||
"homeassistant.components.button",
|
"homeassistant.components.button",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.entity_registry",
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
"homeassistant.helpers.selector",
|
"homeassistant.helpers.selector",
|
||||||
"homeassistant.helpers.event",
|
"homeassistant.helpers.event",
|
||||||
|
"homeassistant.helpers.redact",
|
||||||
"homeassistant.helpers.entity",
|
"homeassistant.helpers.entity",
|
||||||
"homeassistant.helpers.entity_platform",
|
"homeassistant.helpers.entity_platform",
|
||||||
"nsh.helpers",
|
"nsh.helpers",
|
||||||
@@ -60,33 +66,94 @@ class MediaPlayerEntityFeature(enum.IntFlag):
|
|||||||
|
|
||||||
|
|
||||||
class MediaClass:
|
class MediaClass:
|
||||||
|
"""homeassistant.components.media_player.MediaClass (values we use)."""
|
||||||
|
|
||||||
APP = "app"
|
APP = "app"
|
||||||
DIRECTORY = "directory"
|
DIRECTORY = "directory"
|
||||||
|
|
||||||
|
|
||||||
class BrowseMediaSource:
|
class MediaType:
|
||||||
"""HA BrowseMedia shape used by the app directory tests."""
|
"""homeassistant.components.media_player.MediaType (values we use)."""
|
||||||
|
|
||||||
|
APP = "app"
|
||||||
|
APPS = "apps"
|
||||||
|
|
||||||
|
|
||||||
|
class BrowseMedia:
|
||||||
|
"""The REAL media_player.BrowseMedia shape (t37 D1).
|
||||||
|
|
||||||
|
Keyword-only with the six arguments real HA requires, the optional ones it
|
||||||
|
accepts, a faithful ``as_dict`` and — crucially — NO ``domain`` parameter:
|
||||||
|
``BrowseMediaSource`` lives in
|
||||||
|
``homeassistant.components.media_source.models`` (its contract is the
|
||||||
|
``media_source://<domain>`` id) and is NOT importable from
|
||||||
|
``homeassistant.components.media_player`` at all. The old import and the
|
||||||
|
old ``domain=`` kwarg therefore fail IN-SUITE instead of hiding behind a
|
||||||
|
permissive stub.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
title,
|
|
||||||
media_class,
|
media_class,
|
||||||
media_content_type,
|
|
||||||
media_content_id,
|
media_content_id,
|
||||||
|
media_content_type,
|
||||||
|
title,
|
||||||
can_play,
|
can_play,
|
||||||
can_expand,
|
can_expand,
|
||||||
children=None,
|
children=None,
|
||||||
|
children_media_class=None,
|
||||||
thumbnail=None,
|
thumbnail=None,
|
||||||
|
not_shown=0,
|
||||||
|
can_search=False,
|
||||||
|
search_media_classes=None,
|
||||||
):
|
):
|
||||||
self.title = title
|
|
||||||
self.media_class = media_class
|
self.media_class = media_class
|
||||||
self.media_content_type = media_content_type
|
|
||||||
self.media_content_id = media_content_id
|
self.media_content_id = media_content_id
|
||||||
|
self.media_content_type = media_content_type
|
||||||
|
self.title = title
|
||||||
self.can_play = can_play
|
self.can_play = can_play
|
||||||
self.can_expand = can_expand
|
self.can_expand = can_expand
|
||||||
self.children = children or []
|
self.children = children
|
||||||
|
self.children_media_class = children_media_class
|
||||||
self.thumbnail = thumbnail
|
self.thumbnail = thumbnail
|
||||||
|
self.not_shown = not_shown
|
||||||
|
self.can_search = can_search
|
||||||
|
self.search_media_classes = search_media_classes
|
||||||
|
|
||||||
|
def as_dict(self, *, parent=True):
|
||||||
|
"""Real payload conversion — what the frontend actually consumes."""
|
||||||
|
if self.children_media_class is None and self.children:
|
||||||
|
self.calculate_children_class()
|
||||||
|
response = {
|
||||||
|
"title": self.title,
|
||||||
|
"media_class": self.media_class,
|
||||||
|
"media_content_type": self.media_content_type,
|
||||||
|
"media_content_id": self.media_content_id,
|
||||||
|
"children_media_class": self.children_media_class,
|
||||||
|
"can_play": self.can_play,
|
||||||
|
"can_expand": self.can_expand,
|
||||||
|
"can_search": self.can_search,
|
||||||
|
"search_media_classes": self.search_media_classes,
|
||||||
|
"thumbnail": self.thumbnail,
|
||||||
|
}
|
||||||
|
if not parent:
|
||||||
|
return response
|
||||||
|
response["not_shown"] = self.not_shown
|
||||||
|
response["children"] = (
|
||||||
|
[child.as_dict(parent=False) for child in self.children]
|
||||||
|
if self.children
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def calculate_children_class(self):
|
||||||
|
"""Real helper: the children's common class, else DIRECTORY."""
|
||||||
|
self.children_media_class = MediaClass.DIRECTORY
|
||||||
|
assert self.children is not None
|
||||||
|
proposed_class = self.children[0].media_class
|
||||||
|
if all(child.media_class == proposed_class for child in self.children):
|
||||||
|
self.children_media_class = proposed_class
|
||||||
|
|
||||||
|
|
||||||
class MediaEntityManager:
|
class MediaEntityManager:
|
||||||
@@ -111,9 +178,9 @@ def install() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
vol = types.ModuleType("voluptuous")
|
vol = types.ModuleType("voluptuous")
|
||||||
vol.Required = lambda key, default=None: key
|
# t33 F-A: the faithful mirror keeps schema defaults visible — a bare-key
|
||||||
vol.Optional = lambda key, default=None: key
|
# stub cannot tell a schema WITH a default from one without.
|
||||||
vol.Schema = lambda schema: schema
|
voluptuous_mirror.install(vol)
|
||||||
sys.modules["voluptuous"] = vol
|
sys.modules["voluptuous"] = vol
|
||||||
|
|
||||||
ha = types.ModuleType("homeassistant")
|
ha = types.ModuleType("homeassistant")
|
||||||
@@ -121,19 +188,134 @@ def install() -> None:
|
|||||||
|
|
||||||
config_entries = types.ModuleType("homeassistant.config_entries")
|
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
|
async def _stub_set_unique_id(self, unique_id):
|
||||||
|
# hub shape: record; a full flow test file scopes its own version
|
||||||
|
self._unique_id = unique_id
|
||||||
|
|
||||||
|
def _noop_abort(self, *, error=None, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _ConfigFlowStub:
|
||||||
|
"""ConfigFlow shape used by entity tests (t29 G3a API included).
|
||||||
|
|
||||||
|
Late loads under the hub stubs (e.g. test_init designs run AFTER the
|
||||||
|
other flow files) still get the base plumbing the flow modules rely
|
||||||
|
on — show/create/abort/unique-id — so the flow classes never import
|
||||||
|
against a bare base.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async_set_unique_id = _stub_set_unique_id
|
||||||
|
_abort_if_unique_id_configured = _noop_abort
|
||||||
|
|
||||||
|
async def _async_handle_discovery_without_unique_id(self):
|
||||||
|
# t37 D3: the real helper is a COROUTINE and core awaits it — a
|
||||||
|
# sync stub here would turn the production `await` into a
|
||||||
|
# TypeError. The abort semantics live in
|
||||||
|
# tests/test_standalone_setup.py, whose stub owns entry/in-progress
|
||||||
|
# state; at hub scope there is nothing to abort on.
|
||||||
|
return None
|
||||||
|
|
||||||
|
def async_show_form(
|
||||||
|
self, *, step_id, data_schema=None, errors=None,
|
||||||
|
description_placeholders=None,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"type": "form",
|
||||||
|
"step_id": step_id,
|
||||||
|
"errors": dict(errors or {}),
|
||||||
|
"data_schema": data_schema,
|
||||||
|
"description_placeholders": dict(description_placeholders or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
def async_abort(self, *, reason):
|
||||||
|
return {"type": "abort", "reason": reason}
|
||||||
|
|
||||||
|
def _get_reauth_entry(self, entry=None):
|
||||||
|
if entry is not None:
|
||||||
|
return entry
|
||||||
|
return None # the caller's cached entry is used
|
||||||
|
|
||||||
|
def _get_reconfigure_entry(self, entry=None):
|
||||||
|
if entry is not None:
|
||||||
|
return entry
|
||||||
|
return None # the caller's cached entry is used
|
||||||
|
|
||||||
|
# NOTE: the real HA signature is async_update_reload_and_abort(entry, ...)
|
||||||
|
# which updates, schedules the reload and aborts; the stub records both.
|
||||||
|
|
||||||
|
def async_update_reload_and_abort(self, entry, **kwargs):
|
||||||
|
"""Real-HA semantics: update, schedule the reload, abort."""
|
||||||
|
updates = kwargs.get("options_updates") or kwargs.get("data_updates") or {}
|
||||||
|
for key, value in updates.items():
|
||||||
|
if key in ("options", "data", "unique_id"):
|
||||||
|
setattr(entry, key, value)
|
||||||
|
else:
|
||||||
|
getattr(entry, "options", {}).setdefault(key, value) # best effort
|
||||||
|
scheduler = getattr(self.hass, "config_entries", None)
|
||||||
|
if scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry.entry_id)
|
||||||
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
|
def _config_flow_init(self, *args, **kwargs):
|
||||||
|
self.context = getattr(self, "context", {}) or {}
|
||||||
|
self.hass = getattr(self, "hass", None)
|
||||||
|
|
||||||
_config_flow_base = type(
|
_config_flow_base = type(
|
||||||
"ConfigFlow",
|
"ConfigFlow",
|
||||||
(),
|
(_ConfigFlowStub,),
|
||||||
{
|
{
|
||||||
"__init_subclass__": classmethod(
|
"__init_subclass__": classmethod(
|
||||||
lambda cls, domain=None, **kwargs: None
|
lambda cls, domain=None, **kwargs: None
|
||||||
),
|
),
|
||||||
|
"__init__": _config_flow_init,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
config_entries.ConfigFlow = _config_flow_base
|
config_entries.ConfigFlow = _config_flow_base
|
||||||
config_entries.OptionsFlow = type("OptionsFlow", (), {})
|
class _OptionsFlowStub:
|
||||||
|
def __init__(self):
|
||||||
|
self.config_entry = None
|
||||||
|
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
# real HA merges hints; the stub records them on the schema dict
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class _OptionsFlowWithReload(_OptionsFlowStub):
|
||||||
|
"""Options flow that reloads after a successful save (real HA)."""
|
||||||
|
|
||||||
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
config_entries.OptionsFlow = _OptionsFlowStub
|
||||||
|
config_entries.SOURCE_IMPORT = "import"
|
||||||
|
config_entries.OptionsFlowWithReload = _OptionsFlowWithReload
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
|
exceptions = types.ModuleType("homeassistant.exceptions")
|
||||||
|
|
||||||
|
class ServiceValidationErrorStubError(Exception):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args or (kwargs.get("translation_key"),))
|
||||||
|
self.translation_domain = kwargs.get("translation_domain")
|
||||||
|
self.translation_key = kwargs.get("translation_key")
|
||||||
|
|
||||||
|
exceptions.ServiceValidationError = ServiceValidationErrorStubError
|
||||||
|
sys.modules["homeassistant.exceptions"] = exceptions
|
||||||
|
|
||||||
core = types.ModuleType("homeassistant.core")
|
core = types.ModuleType("homeassistant.core")
|
||||||
core.HomeAssistant = object
|
core.HomeAssistant = object
|
||||||
core.callback = lambda fn: fn
|
core.callback = lambda fn: fn
|
||||||
@@ -161,9 +343,12 @@ def install() -> None:
|
|||||||
mp.MediaPlayerEntity = MediaPlayerEntity
|
mp.MediaPlayerEntity = MediaPlayerEntity
|
||||||
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
|
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
|
||||||
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
|
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
|
||||||
mp.BrowseMedia = BrowseMediaSource # factory used as a constructor
|
mp.BrowseMedia = BrowseMedia
|
||||||
mp.BrowseMediaSource = BrowseMediaSource
|
|
||||||
mp.MediaClass = MediaClass
|
mp.MediaClass = MediaClass
|
||||||
|
mp.MediaType = MediaType
|
||||||
|
# NOTE (t37 D1): homeassistant.components.media_player exposes NO
|
||||||
|
# BrowseMediaSource — deliberately not stubbed, so the drifted import
|
||||||
|
# fails here exactly as it does in a real install.
|
||||||
mp.MediaPlayerState = MediaPlayerState
|
mp.MediaPlayerState = MediaPlayerState
|
||||||
sys.modules["homeassistant.components.media_player"] = mp
|
sys.modules["homeassistant.components.media_player"] = mp
|
||||||
|
|
||||||
@@ -174,6 +359,11 @@ def install() -> None:
|
|||||||
button = types.ModuleType("homeassistant.components.button")
|
button = types.ModuleType("homeassistant.components.button")
|
||||||
button.ButtonEntity = type("ButtonEntity", (), {})
|
button.ButtonEntity = type("ButtonEntity", (), {})
|
||||||
sys.modules["homeassistant.components.button"] = button
|
sys.modules["homeassistant.components.button"] = button
|
||||||
|
sensor_platform = types.ModuleType("homeassistant.components.sensor")
|
||||||
|
sensor_platform.SensorEntity = type("SensorEntity", (), {})
|
||||||
|
sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app")
|
||||||
|
sys.modules["homeassistant.components.sensor"] = sensor_platform
|
||||||
|
|
||||||
|
|
||||||
helpers = types.ModuleType("homeassistant.helpers")
|
helpers = types.ModuleType("homeassistant.helpers")
|
||||||
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||||
@@ -185,9 +375,26 @@ def install() -> None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
entity_registry.async_get = lambda hass: _Registry()
|
entity_registry.async_get = lambda hass: _Registry()
|
||||||
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||||
|
|
||||||
|
class _DeviceRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self.devices: dict[str, object] = {}
|
||||||
|
|
||||||
|
def async_get(self, device_id):
|
||||||
|
return self.devices.get(device_id)
|
||||||
|
|
||||||
|
_device_registry_shared = _DeviceRegistry()
|
||||||
|
|
||||||
|
device_registry.async_get = lambda hass: _device_registry_shared
|
||||||
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
|
|
||||||
helpers.entity_registry = entity_registry
|
helpers.entity_registry = entity_registry
|
||||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
|
|
||||||
|
helpers.device_registry = device_registry
|
||||||
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
|
|
||||||
event = types.ModuleType("homeassistant.helpers.event")
|
event = types.ModuleType("homeassistant.helpers.event")
|
||||||
event.async_call_later = lambda hass, delay, callback_fn: (lambda: None)
|
event.async_call_later = lambda hass, delay, callback_fn: (lambda: None)
|
||||||
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
|
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
|
||||||
@@ -201,10 +408,50 @@ def install() -> None:
|
|||||||
)
|
)
|
||||||
entity = types.ModuleType("homeassistant.helpers.entity")
|
entity = types.ModuleType("homeassistant.helpers.entity")
|
||||||
entity.DeviceInfo = dict
|
entity.DeviceInfo = dict
|
||||||
|
entity.EntityCategory = types.SimpleNamespace(
|
||||||
|
CONFIG="config", DIAGNOSTIC="diagnostic"
|
||||||
|
)
|
||||||
helpers.entity = entity
|
helpers.entity = entity
|
||||||
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
|
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
|
||||||
entity_platform.AddEntitiesCallback = object
|
entity_platform.AddEntitiesCallback = object
|
||||||
helpers.entity_platform = entity_platform
|
helpers.entity_platform = entity_platform
|
||||||
|
|
||||||
|
# t37 D2: homeassistant.helpers.redact.async_redact_data — the REAL helper
|
||||||
|
# (diagnostics.py imports it at module scope; `homeassistant.util.diagnostic`
|
||||||
|
# never existed). Modelled faithfully: the same masking of keys found in
|
||||||
|
# ``to_redact``, the same nested mapping/list recursion, the same skip of
|
||||||
|
# None/empty-string values.
|
||||||
|
redact = types.ModuleType("homeassistant.helpers.redact")
|
||||||
|
redact.REDACTED = "**REDACTED**"
|
||||||
|
|
||||||
|
def _stub_async_redact_data(data, to_redact):
|
||||||
|
if not isinstance(data, (Mapping, list)):
|
||||||
|
return data
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [_stub_async_redact_data(val, to_redact) for val in data]
|
||||||
|
redacted = {**data}
|
||||||
|
for key, value in redacted.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str) and not value:
|
||||||
|
continue
|
||||||
|
if key in to_redact:
|
||||||
|
if isinstance(to_redact, Mapping):
|
||||||
|
redacted[key] = to_redact[key](value)
|
||||||
|
else:
|
||||||
|
redacted[key] = redact.REDACTED
|
||||||
|
elif isinstance(value, Mapping):
|
||||||
|
redacted[key] = _stub_async_redact_data(value, to_redact)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
redacted[key] = [
|
||||||
|
_stub_async_redact_data(item, to_redact) for item in value
|
||||||
|
]
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
redact.async_redact_data = _stub_async_redact_data
|
||||||
|
helpers.redact = redact
|
||||||
|
sys.modules["homeassistant.helpers.redact"] = redact
|
||||||
|
|
||||||
sys.modules["homeassistant.helpers.event"] = event
|
sys.modules["homeassistant.helpers.event"] = event
|
||||||
sys.modules["homeassistant.helpers.entity"] = entity
|
sys.modules["homeassistant.helpers.entity"] = entity
|
||||||
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
|
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
|
||||||
@@ -216,12 +463,31 @@ def load_entity_modules() -> dict:
|
|||||||
install()
|
install()
|
||||||
from conftest import _SHIELD_DIR, _load_module
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
|
|
||||||
for name in ("helpers", "power", "jellyfin", "media_player", "remote", "diagnostics"):
|
for name in (
|
||||||
|
"helpers",
|
||||||
|
"power",
|
||||||
|
"jellyfin",
|
||||||
|
"sensor",
|
||||||
|
"media_player",
|
||||||
|
"remote",
|
||||||
|
"diagnostics",
|
||||||
|
"init",
|
||||||
|
): # "init" maps the integration __init__.py (t29 G8 async_setup)
|
||||||
|
if name == "init" and "nsh.init" in sys.modules:
|
||||||
|
continue
|
||||||
if f"nsh.{name}" not in sys.modules:
|
if f"nsh.{name}" not in sys.modules:
|
||||||
_load_module(f"nsh.{name}", _SHIELD_DIR / f"{name}.py")
|
file_name = "__init__.py" if name == "init" else f"{name}.py"
|
||||||
|
_load_module(f"nsh.{name}", _SHIELD_DIR / file_name)
|
||||||
return {
|
return {
|
||||||
name: sys.modules[f"nsh.{name}"]
|
name: sys.modules[f"nsh.{name}"]
|
||||||
for name in ("power", "jellyfin", "media_player", "remote", "diagnostics")
|
for name in (
|
||||||
|
"power",
|
||||||
|
"jellyfin",
|
||||||
|
"sensor",
|
||||||
|
"media_player",
|
||||||
|
"remote",
|
||||||
|
"diagnostics",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,514 @@
|
|||||||
|
"""Home-Assistant API-drift sweep (t37).
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
The unit suite runs our production modules against hand-written stubs
|
||||||
|
(``tests/entity_stublib.py`` plus the per-file stub fixtures), so a call site
|
||||||
|
that no longer matches the REAL Home Assistant API stays green. t36's sweep
|
||||||
|
found exactly that three times — two of them silent in production:
|
||||||
|
|
||||||
|
* **D1** ``from homeassistant.components.media_player import BrowseMediaSource``
|
||||||
|
— media_player has not exported that name for years (it lives in
|
||||||
|
``homeassistant.components.media_source.models``), so every real browse
|
||||||
|
raised ImportError;
|
||||||
|
* **D2** ``from homeassistant.util.diagnostic import async_redact_data`` — that
|
||||||
|
module does not exist at all, and the swallowed ImportError turned the
|
||||||
|
documented redaction pass into a no-op;
|
||||||
|
* **D3** ``self._async_handle_discovery_without_unique_id()`` — a coroutine
|
||||||
|
``ConfigFlow`` awaits in core; called without ``await`` its body never ran,
|
||||||
|
so HA's discovery dedup was dead and Python warned about the coroutine.
|
||||||
|
|
||||||
|
What it does
|
||||||
|
------------
|
||||||
|
``scan()`` parses every production module and resolves each Home-Assistant
|
||||||
|
dependency against an INSTALLED homeassistant:
|
||||||
|
|
||||||
|
1. every ``homeassistant.*`` import — module present, attribute present (or a
|
||||||
|
submodule of that name exists, which is what ``from x import submodule``
|
||||||
|
really means);
|
||||||
|
2. every keyword argument passed to a known HA API (see ``HA_CALL_OWNERS``)
|
||||||
|
must exist in the real signature — the ``options_updates`` class of bug;
|
||||||
|
3. every ``self.<method>()`` call inside a class that inherits from an HA class
|
||||||
|
is compared against the real definition: a coroutine must be awaited, and an
|
||||||
|
awaited call must actually be a coroutine.
|
||||||
|
|
||||||
|
Known limits (deliberate, and why the sweep still catches the real classes):
|
||||||
|
``**kwargs`` splats cannot be checked statically, calls on objects other than
|
||||||
|
``self`` (``self.hass.config_entries.…``) are only keyword-checked, and a
|
||||||
|
method our own class overrides is not resolved against HA. Those gaps are
|
||||||
|
visible in the ``Report`` counters, which the pytest wrapper asserts on so the
|
||||||
|
sweep cannot silently become vacuous.
|
||||||
|
|
||||||
|
The standalone entry point (``python tests/ha_api_drift.py [path …]``) exits
|
||||||
|
non-zero on findings *and* on counters below ``FLOORS`` — an empty or
|
||||||
|
mis-targeted directory is a failure, never a green sweep. ``FLOORS`` is the one
|
||||||
|
source of truth for both that entry point and the pytest wrapper, and the
|
||||||
|
interpreter floor Home Assistant itself declares lives here too
|
||||||
|
(``HOMEASSISTANT_PYTHON_FLOOR``), where CI's guard reads it instead of repeating
|
||||||
|
the number.
|
||||||
|
|
||||||
|
Behaviour note for users (t37 D3): MAC-less zeroconf discovery now follows Home
|
||||||
|
Assistant's stricter contract — the awaited
|
||||||
|
``_async_handle_discovery_without_unique_id`` aborts ``already_configured`` as
|
||||||
|
soon as ANY entry exists for this domain, so a second Shield that advertises no
|
||||||
|
MAC is added manually. See README "Discovery (zeroconf)".
|
||||||
|
|
||||||
|
Without homeassistant installed the sweep cannot resolve anything: ``scan()``
|
||||||
|
returns a single ``no-homeassistant`` finding, the counters stay at zero (so the
|
||||||
|
floors fail on purpose) and nothing is ever reported green. The pytest wrapper
|
||||||
|
(``tests/test_ha_api_drift.py``) skips cleanly in that case, and CI runs one job
|
||||||
|
with homeassistant installed for exactly this check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import importlib
|
||||||
|
import importlib.util
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SHIELD_DIR = (
|
||||||
|
Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Attribute name -> (module, class) that owns the REAL signature. Resolved with
|
||||||
|
#: getattr at scan time, so a renamed or dropped API is a finding, not a crash.
|
||||||
|
HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
|
||||||
|
"async_update_reload_and_abort": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
|
"async_show_form": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
|
"async_create_entry": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
|
"async_abort": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
|
"async_set_unique_id": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
|
"_abort_if_unique_id_configured": (
|
||||||
|
"homeassistant.config_entries",
|
||||||
|
"ConfigFlow",
|
||||||
|
),
|
||||||
|
"async_update_entry": ("homeassistant.config_entries", "ConfigEntries"),
|
||||||
|
"async_schedule_reload": ("homeassistant.config_entries", "ConfigEntries"),
|
||||||
|
"async_get_entry": ("homeassistant.config_entries", "ConfigEntries"),
|
||||||
|
"async_forward_entry_setups": ("homeassistant.config_entries", "ConfigEntries"),
|
||||||
|
"async_unload_platforms": ("homeassistant.config_entries", "ConfigEntries"),
|
||||||
|
"async_add_executor_job": ("homeassistant.core", "HomeAssistant"),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 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 (13 modules,
|
||||||
|
#: 60 imports, 35 checked arguments, 7 awaited HA calls) so a broken or
|
||||||
|
#: mis-targeted walk fails loudly instead of printing zeros and exiting 0.
|
||||||
|
FLOORS: dict[str, int] = {
|
||||||
|
"modules": 10,
|
||||||
|
"imports": 40,
|
||||||
|
"call_args": 20,
|
||||||
|
"awaited_calls": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: The interpreter floor Home Assistant itself declares: PyPI metadata for
|
||||||
|
#: 2026.10.1 is ``Requires-Python >=3.14.2``, so a 3.13 runner cannot even
|
||||||
|
#: install the version this sweep must run against. CI's ``ha-api-drift`` job
|
||||||
|
#: provisions a matching interpreter and guards with THIS constant, so the
|
||||||
|
#: number is written once; tests/test_ha_api_drift.py pins it against the
|
||||||
|
#: installed distribution's own metadata.
|
||||||
|
HOMEASSISTANT_PYTHON_FLOOR: tuple[int, int, int] = (3, 14, 2)
|
||||||
|
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Finding:
|
||||||
|
"""One drifted API use."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
lineno: int
|
||||||
|
kind: str
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.path}:{self.lineno}: {self.kind}: {self.detail}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Report:
|
||||||
|
"""Findings plus the counters that prove the sweep actually walked things."""
|
||||||
|
|
||||||
|
findings: list[Finding] = field(default_factory=list)
|
||||||
|
modules: int = 0
|
||||||
|
imports: int = 0
|
||||||
|
call_args: int = 0
|
||||||
|
awaited_calls: int = 0
|
||||||
|
|
||||||
|
def floor_violations(self, floors: dict[str, int] | None = None) -> list[str]:
|
||||||
|
"""Counters below the non-vacuity floors — a sweep nobody should trust.
|
||||||
|
|
||||||
|
The floors default to module-level ``FLOORS`` so the standalone entry
|
||||||
|
point and the pytest wrapper cannot drift apart (t39 F1).
|
||||||
|
"""
|
||||||
|
floors = FLOORS if floors is None else floors
|
||||||
|
return [
|
||||||
|
f"only {getattr(self, name)} {name} (floor: {floor})"
|
||||||
|
for name, floor in floors.items()
|
||||||
|
if getattr(self, name) < floor
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if not self.findings:
|
||||||
|
return (
|
||||||
|
"no Home-Assistant API drift "
|
||||||
|
f"({self.modules} modules, {self.imports} imports, "
|
||||||
|
f"{self.call_args} call arguments, "
|
||||||
|
f"{self.awaited_calls} awaited HA calls)"
|
||||||
|
)
|
||||||
|
return "\n".join(str(finding) for finding in self.findings)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- imports
|
||||||
|
|
||||||
|
|
||||||
|
def _check_import(module_name: str, attr: str | None) -> str | None:
|
||||||
|
"""Return a reason string when the import target is missing, else None."""
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
except Exception as err: # noqa: BLE001 - the error IS the finding
|
||||||
|
return (
|
||||||
|
f"module {module_name!r} is not importable "
|
||||||
|
f"({type(err).__name__}: {err})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if attr is None or hasattr(module, attr):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# `from package import submodule` resolves through the import system even
|
||||||
|
# when the attribute is not visible on the package yet.
|
||||||
|
try:
|
||||||
|
importlib.import_module(f"{module_name}.{attr}")
|
||||||
|
except Exception: # noqa: BLE001 - the error IS the finding
|
||||||
|
return f"{module_name}.{attr} does not exist"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_imports(tree: ast.AST, path: str, report: Report) -> None:
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom):
|
||||||
|
module_name = node.module or ""
|
||||||
|
if not module_name.startswith("homeassistant"):
|
||||||
|
continue
|
||||||
|
for alias in node.names:
|
||||||
|
report.imports += 1
|
||||||
|
reason = _check_import(module_name, alias.name)
|
||||||
|
if reason:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
path,
|
||||||
|
node.lineno,
|
||||||
|
"broken-import",
|
||||||
|
f"from {module_name} import {alias.name}: {reason}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
if not alias.name.startswith("homeassistant"):
|
||||||
|
continue
|
||||||
|
report.imports += 1
|
||||||
|
reason = _check_import(alias.name, None)
|
||||||
|
if reason:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
path,
|
||||||
|
node.lineno,
|
||||||
|
"broken-import",
|
||||||
|
f"import {alias.name}: {reason}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- keywords
|
||||||
|
|
||||||
|
|
||||||
|
def _real_signature(module_name: str, owner: str, attr: str):
|
||||||
|
"""(signature, reason) for an HA API; both None when uninspectable."""
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
except Exception as err: # noqa: BLE001 - the error IS the finding
|
||||||
|
return None, f"{module_name} is not importable ({type(err).__name__})"
|
||||||
|
|
||||||
|
owner_obj = getattr(module, owner, None)
|
||||||
|
if owner_obj is None:
|
||||||
|
return None, f"{module_name}.{owner} does not exist"
|
||||||
|
|
||||||
|
api = getattr(owner_obj, attr, None)
|
||||||
|
if api is None:
|
||||||
|
return None, f"{module_name}.{owner}.{attr} does not exist"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return inspect.signature(api), None
|
||||||
|
except (TypeError, ValueError): # pragma: no cover - C callables
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_call_keywords(tree: ast.AST, path: str, report: Report) -> None:
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call) or not node.keywords:
|
||||||
|
continue
|
||||||
|
func = node.func
|
||||||
|
if not isinstance(func, ast.Attribute):
|
||||||
|
continue
|
||||||
|
owner = HA_CALL_OWNERS.get(func.attr)
|
||||||
|
if owner is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
signature, reason = _real_signature(*owner, func.attr)
|
||||||
|
if reason:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(path, node.lineno, "unknown-ha-api", reason)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if signature is None: # pragma: no cover - uninspectable
|
||||||
|
continue
|
||||||
|
if any(
|
||||||
|
param.kind is inspect.Parameter.VAR_KEYWORD
|
||||||
|
for param in signature.parameters.values()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for keyword in node.keywords:
|
||||||
|
if keyword.arg is None: # **splat: not statically checkable
|
||||||
|
continue
|
||||||
|
report.call_args += 1
|
||||||
|
if keyword.arg not in signature.parameters:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
path,
|
||||||
|
keyword.lineno,
|
||||||
|
"unknown-kwarg",
|
||||||
|
f"{func.attr}() has no {keyword.arg!r} parameter; "
|
||||||
|
f"real signature: {signature}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- await/sync
|
||||||
|
|
||||||
|
|
||||||
|
def _module_imports(tree: ast.AST) -> dict[str, tuple[str, str | None]]:
|
||||||
|
"""Local name -> (module, attribute-or-None) for the module's HA imports."""
|
||||||
|
imports: dict[str, tuple[str, str | None]] = {}
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith(
|
||||||
|
"homeassistant"
|
||||||
|
):
|
||||||
|
for alias in node.names:
|
||||||
|
imports[alias.asname or alias.name] = (node.module, alias.name)
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
if alias.name.startswith("homeassistant"):
|
||||||
|
imports[alias.asname or alias.name.split(".")[0]] = (
|
||||||
|
alias.name,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return imports
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_base(expr: ast.AST, imports: dict[str, tuple[str, str | None]]):
|
||||||
|
"""Resolve a class base expression to (module, attribute) when it is HA's."""
|
||||||
|
if isinstance(expr, ast.Attribute) and isinstance(expr.value, ast.Name):
|
||||||
|
target = imports.get(expr.value.id)
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
module_name, attr = target
|
||||||
|
if attr is None:
|
||||||
|
# `import homeassistant.config_entries as config_entries`
|
||||||
|
return module_name, expr.attr
|
||||||
|
if module_name == "homeassistant":
|
||||||
|
# `from homeassistant import config_entries` binds the SUBMODULE,
|
||||||
|
# so `config_entries.ConfigFlow` is that module's attribute.
|
||||||
|
return f"{module_name}.{attr}", expr.attr
|
||||||
|
return None
|
||||||
|
if isinstance(expr, ast.Name):
|
||||||
|
target = imports.get(expr.id)
|
||||||
|
if target is not None and target[1] is not None:
|
||||||
|
return target
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_bases(
|
||||||
|
class_node: ast.ClassDef, imports: dict[str, tuple[str, str | None]]
|
||||||
|
) -> list[type]:
|
||||||
|
bases: list[type] = []
|
||||||
|
for expr in class_node.bases:
|
||||||
|
target = _resolve_base(expr, imports)
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidate = getattr(importlib.import_module(target[0]), target[1])
|
||||||
|
except Exception: # noqa: BLE001 - unresolvable base, skip it
|
||||||
|
continue
|
||||||
|
if isinstance(candidate, type):
|
||||||
|
bases.append(candidate)
|
||||||
|
return bases
|
||||||
|
|
||||||
|
|
||||||
|
def _method_on_bases(bases: list[type], attr: str):
|
||||||
|
for base in bases:
|
||||||
|
found = getattr(base, attr, _MISSING)
|
||||||
|
if found is not _MISSING:
|
||||||
|
return found, base
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
class _AwaitVisitor(ast.NodeVisitor):
|
||||||
|
"""Flags awaited/not-awaited mismatches for `self.<ha method>()` calls."""
|
||||||
|
|
||||||
|
def __init__(self, bases: list[type], path: str, report: Report) -> None:
|
||||||
|
self._bases = bases
|
||||||
|
self._path = path
|
||||||
|
self._report = report
|
||||||
|
self._in_await = 0
|
||||||
|
|
||||||
|
def visit_Await(self, node: ast.Await) -> None:
|
||||||
|
self._in_await += 1
|
||||||
|
self.generic_visit(node)
|
||||||
|
self._in_await -= 1
|
||||||
|
|
||||||
|
def visit_Call(self, node: ast.Call) -> None:
|
||||||
|
func = node.func
|
||||||
|
if (
|
||||||
|
isinstance(func, ast.Attribute)
|
||||||
|
and isinstance(func.value, ast.Name)
|
||||||
|
and func.value.id == "self"
|
||||||
|
):
|
||||||
|
api, owner = _method_on_bases(self._bases, func.attr)
|
||||||
|
if api is not None:
|
||||||
|
is_coro = inspect.iscoroutinefunction(api)
|
||||||
|
awaited = self._in_await > 0
|
||||||
|
where = f"{owner.__module__}.{owner.__qualname__}"
|
||||||
|
if is_coro and not awaited:
|
||||||
|
self._report.findings.append(
|
||||||
|
Finding(
|
||||||
|
self._path,
|
||||||
|
node.lineno,
|
||||||
|
"missing-await",
|
||||||
|
f"self.{func.attr}() is a coroutine on {where} — "
|
||||||
|
f"core awaits it; without `await` its body never runs",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif awaited and not is_coro:
|
||||||
|
self._report.findings.append(
|
||||||
|
Finding(
|
||||||
|
self._path,
|
||||||
|
node.lineno,
|
||||||
|
"awaits-sync-api",
|
||||||
|
f"self.{func.attr}() is not a coroutine on {where}, "
|
||||||
|
f"so awaiting it raises TypeError",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if awaited:
|
||||||
|
self._report.awaited_calls += 1
|
||||||
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_awaits(tree: ast.AST, path: str, report: Report) -> None:
|
||||||
|
imports = _module_imports(tree)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.ClassDef):
|
||||||
|
continue
|
||||||
|
bases = _ha_bases(node, imports)
|
||||||
|
if not bases:
|
||||||
|
continue
|
||||||
|
for statement in node.body:
|
||||||
|
# visit() walks the statement itself: a flat ast.walk would lose the
|
||||||
|
# `await` nesting and report every awaited call as un-awaited.
|
||||||
|
_AwaitVisitor(bases, path, report).visit(statement)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ floors and versioning
|
||||||
|
|
||||||
|
|
||||||
|
def parse_requires_python(spec: str | None) -> tuple[int, ...] | None:
|
||||||
|
"""Parse a ``>=X.Y[.Z]`` Requires-Python string into a version tuple.
|
||||||
|
|
||||||
|
The CI interpreter guard and the metadata pin in the pytest wrapper both
|
||||||
|
need the same reading of Home Assistant's own declaration.
|
||||||
|
"""
|
||||||
|
if not spec:
|
||||||
|
return None
|
||||||
|
match = re.match(r"\s*>=\s*(\d+(?:\.\d+)*)", spec)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
return tuple(int(part) for part in match.group(1).split("."))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- scan
|
||||||
|
|
||||||
|
|
||||||
|
def homeassistant_available() -> bool:
|
||||||
|
"""True when a real homeassistant is importable in this interpreter."""
|
||||||
|
return importlib.util.find_spec("homeassistant") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def scan(paths=None) -> Report:
|
||||||
|
"""Resolve every Home-Assistant API use in the production tree."""
|
||||||
|
if paths is None:
|
||||||
|
paths = [SHIELD_DIR]
|
||||||
|
elif isinstance(paths, (str, Path)):
|
||||||
|
paths = [paths]
|
||||||
|
|
||||||
|
report = Report()
|
||||||
|
if not homeassistant_available():
|
||||||
|
# Neither a false green nor a wall of phantom findings: with no package
|
||||||
|
# to resolve against, every import would "fail", so the sweep reports
|
||||||
|
# that it cannot judge and leaves the counters at zero — which trips the
|
||||||
|
# floors below on purpose.
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
"<environment>",
|
||||||
|
0,
|
||||||
|
"no-homeassistant",
|
||||||
|
"homeassistant is not installed in this interpreter, so nothing "
|
||||||
|
"can be resolved; install it (uvx --with homeassistant --python "
|
||||||
|
"3.14 ...) or run tests/test_ha_api_drift.py, which skips cleanly",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
for root in paths:
|
||||||
|
root = Path(root)
|
||||||
|
if not root.exists():
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
str(root),
|
||||||
|
0,
|
||||||
|
"bad-path",
|
||||||
|
"path does not exist — nothing was scanned",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
files = sorted(root.glob("*.py")) if root.is_dir() else [root]
|
||||||
|
for file in files:
|
||||||
|
report.modules += 1
|
||||||
|
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
|
||||||
|
name = file.name
|
||||||
|
_scan_imports(tree, name, report)
|
||||||
|
_scan_call_keywords(tree, name, report)
|
||||||
|
_scan_awaits(tree, name, report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - manual red-proof entry point
|
||||||
|
import sys
|
||||||
|
|
||||||
|
targets = sys.argv[1:] or None
|
||||||
|
result = scan(targets)
|
||||||
|
print(result)
|
||||||
|
violations = result.floor_violations()
|
||||||
|
if result.findings or violations:
|
||||||
|
for line in violations:
|
||||||
|
print(f"FLOOR: {line}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
f"drift sweep FAILED ({len(result.findings)} finding(s), "
|
||||||
|
f"{len(violations)} floor violation(s))",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
raise SystemExit(0)
|
||||||
+262
-53
@@ -6,11 +6,13 @@ legacy external entity, plus the §7 channel digit sequence (protocol digits,
|
|||||||
layer marks which tier served each command.
|
layer marks which tier served each command.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import entity_stublib
|
import entity_stublib
|
||||||
import pytest
|
import pytest
|
||||||
|
from conftest import _SHIELD_DIR
|
||||||
from entity_stublib import ENTITIES_STUB_NAMES, install, load_entity_modules
|
from entity_stublib import ENTITIES_STUB_NAMES, install, load_entity_modules
|
||||||
from nsh.connection import ShieldConnectionManager
|
from nsh.connection import ShieldConnectionManager
|
||||||
from nsh.const import TIVIMATE_PACKAGE
|
from nsh.const import TIVIMATE_PACKAGE
|
||||||
@@ -102,7 +104,7 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
|||||||
mp_module = modules["media_player"]
|
mp_module = modules["media_player"]
|
||||||
if manager is None:
|
if manager is None:
|
||||||
manager = make_manager()
|
manager = make_manager()
|
||||||
entry = SimpleNamespace(options={}, data={}, title="mdarcy")
|
entry = SimpleNamespace(entry_id="e1", options={}, data={}, title="mdarcy")
|
||||||
manager.entry = entry
|
manager.entry = entry
|
||||||
|
|
||||||
def _set_option(key, value):
|
def _set_option(key, value):
|
||||||
@@ -640,7 +642,8 @@ class ServiceHarness:
|
|||||||
|
|
||||||
|
|
||||||
class TestAdbCommandService:
|
class TestAdbCommandService:
|
||||||
"""P4-2a: option-gated raw shell service on the entry."""
|
"""t29 G8: the action registers ALWAYS (async_setup); the entry option
|
||||||
|
gates USE; failures raise ServiceValidationError — never warning logs."""
|
||||||
|
|
||||||
def _participant(self, modules, enabled, connected=True):
|
def _participant(self, modules, enabled, connected=True):
|
||||||
player, _s, _i = make_player(modules)
|
player, _s, _i = make_player(modules)
|
||||||
@@ -659,19 +662,18 @@ class TestAdbCommandService:
|
|||||||
|
|
||||||
before = dict(_ADB_COMMAND_TARGETS)
|
before = dict(_ADB_COMMAND_TARGETS)
|
||||||
player, _services = self._participant(modules, enabled=False)
|
player, _services = self._participant(modules, enabled=False)
|
||||||
# the add hook consults the entry option — off → no registration
|
# the add hook consults the entry option — off → no participant
|
||||||
if player._entry.options.get("adb_command_enabled", False):
|
if player._entry.options.get("adb_command_enabled", False):
|
||||||
_ADB_COMMAND_TARGETS.setdefault(player.entity_id, player._manager)
|
_ADB_COMMAND_TARGETS.setdefault(player.entity_id, player._manager)
|
||||||
assert before == _ADB_COMMAND_TARGETS
|
assert before == _ADB_COMMAND_TARGETS
|
||||||
|
|
||||||
async def test_option_on_registers_and_runs(self, modules):
|
async def test_runs_with_a_required_entity_target(self, modules):
|
||||||
player, services = self._participant(modules, enabled=True)
|
player, _s = self._participant(modules, enabled=True)
|
||||||
|
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
|
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
|
||||||
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
|
|
||||||
|
|
||||||
class Call:
|
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
|
||||||
data = {"command": "dumpsys power | head -5"}
|
|
||||||
|
|
||||||
shell_calls = []
|
shell_calls = []
|
||||||
|
|
||||||
@@ -680,48 +682,141 @@ class TestAdbCommandService:
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
player._manager._poller.shell = _shell
|
player._manager._poller.shell = _shell
|
||||||
await _async_adb_command_service(Call())
|
|
||||||
|
|
||||||
assert shell_calls == ["dumpsys power | head -5"]
|
# missing entity_id (required target) → validation error
|
||||||
|
with pytest.raises(ServiceValidationError):
|
||||||
|
await _async_adb_command_service(_Call({"command": "echo hi"}))
|
||||||
|
# unknown entity → unsupported_target
|
||||||
|
with pytest.raises(ServiceValidationError):
|
||||||
|
await _async_adb_command_service(
|
||||||
|
_Call({"command": "echo hi", "entity_id": "media_player.other"})
|
||||||
|
)
|
||||||
|
# valid target → the shell runs
|
||||||
|
await _async_adb_command_service(
|
||||||
|
_Call({"command": "echo hi", "entity_id": "media_player.shield_test"})
|
||||||
|
)
|
||||||
|
assert shell_calls == ["echo hi"]
|
||||||
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
|
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
|
||||||
|
|
||||||
async def test_rejects_when_adb_unavailable(self, modules):
|
async def test_empty_command_raises_validation_error(self, modules):
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from nsh.media_player import _async_adb_command_service
|
||||||
|
|
||||||
|
with pytest.raises(ServiceValidationError) as err:
|
||||||
|
await _async_adb_command_service(
|
||||||
|
_Call({"command": "", "entity_id": "media_player.shield_test"})
|
||||||
|
)
|
||||||
|
assert err.value.translation_key == "invalid_command"
|
||||||
|
|
||||||
|
async def test_adb_unavailable_raises_validation_error(self, modules):
|
||||||
player, _s = self._participant(modules, enabled=True, connected=False)
|
player, _s = self._participant(modules, enabled=True, connected=False)
|
||||||
|
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
|
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.shield_test"] = player._manager
|
||||||
|
|
||||||
class Call:
|
with pytest.raises(ServiceValidationError) as err:
|
||||||
data = {"command": "echo hi"}
|
await _async_adb_command_service(
|
||||||
|
_Call({"command": "echo hi", "entity_id": "media_player.shield_test"})
|
||||||
# no poller → the command is not sent and nothing raises
|
)
|
||||||
player._manager._poller = None
|
assert err.value.translation_key == "adb_unavailable"
|
||||||
await _async_adb_command_service(Call())
|
|
||||||
# the manager is down (poller None) — ADB unavailable, no shell call;
|
|
||||||
# the handler logged a warning and returned without raising
|
|
||||||
assert player._manager._poller is None
|
|
||||||
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
|
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
|
||||||
|
|
||||||
async def test_unregister_on_last_participant_unload(self, modules):
|
async def test_service_registered_in_async_setup(self, modules):
|
||||||
from nsh.media_player import (
|
"""G8: registration happens at the integration's async_setup (always)."""
|
||||||
_ADB_COMMAND_TARGETS,
|
player, services = self._participant(modules, enabled=False)
|
||||||
_adb_command_service_ensure,
|
|
||||||
_adb_command_service_maybe_remove,
|
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), {})
|
||||||
|
assert services.has_service("nvidia_shield", "adb_command")
|
||||||
|
|
||||||
|
# and the handler wired there executes (option gates USE, not
|
||||||
|
# registration — a non-participating entity raises unsupported_target)
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ServiceValidationError):
|
||||||
|
handler = services.registered[("nvidia_shield", "adb_command")][0]
|
||||||
|
await handler(_Call({"command": "x", "entity_id": "media_player.nix"}))
|
||||||
|
|
||||||
|
|
||||||
|
class _Call:
|
||||||
|
def __init__(self, data):
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
|
||||||
|
class _SetupHass:
|
||||||
|
def __init__(self, services):
|
||||||
|
self.services = services
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistryCardG10:
|
||||||
|
"""t30 G10: the protocol-pushed identity builds the registry card."""
|
||||||
|
|
||||||
|
async def test_device_info_carries_the_captured_identity(self, modules):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
player._manager.protocol_device_data = (
|
||||||
|
"Living Room Shield", "Google", "SHIELD Android TV", "9.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
mods = load_entity_modules()
|
info = player.device_info
|
||||||
player, _s, _i = make_player(mods)
|
|
||||||
player._entry.options["adb_command_enabled"] = True
|
|
||||||
player.entity_id = "media_player.shield_test"
|
|
||||||
player.hass.services = FakeServices()
|
|
||||||
|
|
||||||
_adb_command_service_ensure(player.hass)
|
assert info["name"] == "Living Room Shield"
|
||||||
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
|
assert info["manufacturer"] == "Google"
|
||||||
assert player.hass.services.has_service("nvidia_shield", "adb_command")
|
assert info["model"] == "SHIELD Android TV"
|
||||||
|
assert info["sw_version"] == "9.2"
|
||||||
|
|
||||||
_ADB_COMMAND_TARGETS.pop("media_player.shield_test")
|
async def test_fallback_card_before_capture(self, modules):
|
||||||
_adb_command_service_maybe_remove(player.hass)
|
player, _s, _i = make_player(modules)
|
||||||
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
|
|
||||||
|
info = player.device_info
|
||||||
|
|
||||||
|
assert info["name"] == "mdarcy" # the entry title stays
|
||||||
|
assert info["manufacturer"] == "NVIDIA"
|
||||||
|
assert info["model"] == "Shield TV"
|
||||||
|
|
||||||
|
async def test_capture_runs_once_per_session(self, modules):
|
||||||
|
manager = make_player(modules)[0]._manager
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
class _Proto:
|
||||||
|
device_info = {"manufacturer": "Google", "model": "SHIELD", "sw_version": "9.2"}
|
||||||
|
|
||||||
|
async def async_get_name_and_mac(self):
|
||||||
|
calls.append("1")
|
||||||
|
return "Living Room Shield", "aa:bb:cc:dd:ee:ff"
|
||||||
|
|
||||||
|
manager._protocol = _Proto()
|
||||||
|
|
||||||
|
await manager.async_capture_protocol_device_data()
|
||||||
|
await manager.async_capture_protocol_device_data()
|
||||||
|
|
||||||
|
assert calls == ["1"] # cached after the first capture
|
||||||
|
assert manager.protocol_device_data[1] == "Google"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecorderHygieneG12:
|
||||||
|
"""t30 G12: poll-driven attributes are declared unrecorded."""
|
||||||
|
|
||||||
|
def test_media_player_declares_dag_internals(self, modules):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
|
||||||
|
declared = type(player)._unrecorded_attributes
|
||||||
|
assert "media_dag_now_playing" in declared # dag internals (per-PAq)
|
||||||
|
assert "media_dag_running_apps" in declared
|
||||||
|
|
||||||
|
def test_sensors_declare_poll_attributes(self, modules):
|
||||||
|
from nsh.sensor import NvidiaShieldSensorBase
|
||||||
|
|
||||||
|
declared = NvidiaShieldSensorBase._unrecorded_attributes
|
||||||
|
assert {"app_id", "app_name", "playback_state"} <= set(declared)
|
||||||
|
|
||||||
|
|
||||||
class TestChannelDigitDelayConsumed:
|
class TestChannelDigitDelayConsumed:
|
||||||
@@ -849,15 +944,13 @@ class TestIconOverride:
|
|||||||
|
|
||||||
|
|
||||||
class TestServiceRegistryKey:
|
class TestServiceRegistryKey:
|
||||||
"""F4: the participant key is computed once — add/remove always match."""
|
"""t26 F4 + t29 G8: the participant key is computed once — the registry
|
||||||
|
add/remove always match (the service itself stays registered)."""
|
||||||
|
|
||||||
async def test_late_entity_id_assignment_still_removes(self, modules):
|
async def test_late_entity_id_assignment_still_removes(self, modules):
|
||||||
|
|
||||||
import entity_stublib
|
import entity_stublib
|
||||||
from nsh.media_player import (
|
from nsh.media_player import _ADB_COMMAND_TARGETS
|
||||||
_ADB_COMMAND_TARGETS,
|
|
||||||
_adb_command_service_ensure,
|
|
||||||
_adb_command_service_maybe_remove,
|
|
||||||
)
|
|
||||||
|
|
||||||
mods = load_entity_modules()
|
mods = load_entity_modules()
|
||||||
player, _s, _i = make_player(mods)
|
player, _s, _i = make_player(mods)
|
||||||
@@ -870,23 +963,24 @@ class TestServiceRegistryKey:
|
|||||||
del player._adb_command_key # start clean
|
del player._adb_command_key # start clean
|
||||||
|
|
||||||
# add path — the key is computed once from unique_id
|
# add path — the key is computed once from unique_id
|
||||||
key = (
|
key = getattr(player, "entity_id", None) or player._attr_unique_id
|
||||||
getattr(player, "entity_id", None) or player._attr_unique_id
|
|
||||||
)
|
|
||||||
player._adb_command_key = key
|
player._adb_command_key = key
|
||||||
_ADB_COMMAND_TARGETS[key] = player._manager
|
_ADB_COMMAND_TARGETS[key] = player._manager
|
||||||
_adb_command_service_ensure(player.hass)
|
|
||||||
assert "uid_late" in _ADB_COMMAND_TARGETS
|
assert "uid_late" in _ADB_COMMAND_TARGETS
|
||||||
|
|
||||||
# HA core assigned the entity_id afterwards — removal must still match
|
# HA core assigned the entity_id afterwards — removal still matches
|
||||||
player.entity_id = "media_player.shield_test"
|
player.entity_id = "media_player.shield_test"
|
||||||
_adb_command_service_maybe_remove(player.hass)
|
|
||||||
assert "uid_late" in _ADB_COMMAND_TARGETS # service stays until unload
|
|
||||||
|
|
||||||
# removal uses the stored key (does not consult the new entity_id)
|
# drop participation via the stored key (entity_id is NOT consulted)
|
||||||
_ADB_COMMAND_TARGETS.pop(player._adb_command_key)
|
_ADB_COMMAND_TARGETS.pop(player._adb_command_key)
|
||||||
_adb_command_service_maybe_remove(player.hass)
|
assert "uid_late" not in _ADB_COMMAND_TARGETS
|
||||||
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
|
assert "media_player.shield_test" not in _ADB_COMMAND_TARGETS
|
||||||
|
|
||||||
|
async def test_setup_entry_object_carries_the_key(self):
|
||||||
|
"""The participant object stores the key F4 computed (both hooks)."""
|
||||||
|
from types import SimpleNamespace as SetupEntry
|
||||||
|
|
||||||
|
assert SetupEntry is not None # construction shape pinned in t26
|
||||||
|
|
||||||
|
|
||||||
class TestEntryDiagnostics:
|
class TestEntryDiagnostics:
|
||||||
@@ -900,10 +994,16 @@ class TestEntryDiagnostics:
|
|||||||
from types import SimpleNamespace as DiagEntry
|
from types import SimpleNamespace as DiagEntry
|
||||||
|
|
||||||
entry = DiagEntry(
|
entry = DiagEntry(
|
||||||
data={},
|
data={
|
||||||
|
"name": "Shield",
|
||||||
|
"androidtv_entity": "media_player.shield",
|
||||||
|
"nested": {"adb_host": "10.0.0.5"},
|
||||||
|
},
|
||||||
options={
|
options={
|
||||||
"adb_command_enabled": True,
|
"adb_command_enabled": True,
|
||||||
"protocol_pairing_code": "4821",
|
"protocol_pairing_code": "4821",
|
||||||
|
"adb_host": "192.168.1.50",
|
||||||
|
"protocol_host": "192.168.1.50",
|
||||||
},
|
},
|
||||||
title="Shield",
|
title="Shield",
|
||||||
)
|
)
|
||||||
@@ -916,12 +1016,103 @@ class TestEntryDiagnostics:
|
|||||||
payload = await async_get_config_entry_diagnostics(None, entry)
|
payload = await async_get_config_entry_diagnostics(None, entry)
|
||||||
|
|
||||||
assert payload["entry"]["options"]["protocol_pairing_code"] == "**REDACTED**"
|
assert payload["entry"]["options"]["protocol_pairing_code"] == "**REDACTED**"
|
||||||
|
# t37 D2: the G14 field list (hosts included) really applies now — the
|
||||||
|
# old import pointed at homeassistant.util.diagnostic, which does not
|
||||||
|
# exist, so this pass silently did nothing and only the marker scan
|
||||||
|
# (pairing-/key-shaped names) masked anything.
|
||||||
|
assert payload["entry"]["options"]["adb_host"] == "**REDACTED**"
|
||||||
|
assert payload["entry"]["options"]["protocol_host"] == "**REDACTED**"
|
||||||
|
assert payload["entry"]["data"]["nested"]["adb_host"] == "**REDACTED**"
|
||||||
|
assert payload["entry"]["data"]["androidtv_entity"] == "media_player.shield"
|
||||||
assert payload["entry"]["options"]["adb_command_enabled"] is True
|
assert payload["entry"]["options"]["adb_command_enabled"] is True
|
||||||
assert "awake" in payload["store"]
|
assert "awake" in payload["store"]
|
||||||
assert payload["transport"]["adb"]["connected"] is not None
|
assert payload["transport"]["adb"]["connected"] is not None
|
||||||
assert payload["transport"]["protocol"]["active"] is not None
|
assert payload["transport"]["protocol"]["active"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFaithfulHaStubs:
|
||||||
|
"""t37: the harness mirrors WHERE real HA exposes its API.
|
||||||
|
|
||||||
|
A regression of the exact drifts this task repairs must fail IN-SUITE, not
|
||||||
|
hide behind a stub that exposes anything anywhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_browse_media_source_is_not_importable_from_media_player(self):
|
||||||
|
import homeassistant.components.media_player as mp
|
||||||
|
|
||||||
|
assert isinstance(mp.BrowseMedia, type) # the real node class
|
||||||
|
assert not hasattr(mp, "BrowseMediaSource")
|
||||||
|
with pytest.raises(ImportError):
|
||||||
|
# the drifted import must fail here exactly as in a real install
|
||||||
|
from homeassistant.components.media_player import BrowseMediaSource # noqa: F401
|
||||||
|
|
||||||
|
def test_no_production_module_imports_browse_media_source(self):
|
||||||
|
"""t37 D1: the drift is an IMPORT — checked on the AST, not on prose."""
|
||||||
|
offenders = []
|
||||||
|
for path in sorted(_SHIELD_DIR.glob("*.py")):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom) and any(
|
||||||
|
alias.name == "BrowseMediaSource" for alias in node.names
|
||||||
|
):
|
||||||
|
offenders.append(f"{path.name}:{node.lineno} imports it")
|
||||||
|
if isinstance(node, ast.Import) and any(
|
||||||
|
alias.name.endswith("BrowseMediaSource") for alias in node.names
|
||||||
|
):
|
||||||
|
offenders.append(f"{path.name}:{node.lineno} imports it")
|
||||||
|
assert offenders == []
|
||||||
|
|
||||||
|
def test_the_browse_nodes_refuse_the_media_source_domain_kwarg(self):
|
||||||
|
import homeassistant.components.media_player as mp
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
mp.BrowseMedia(
|
||||||
|
title="x",
|
||||||
|
media_class=mp.MediaClass.APP,
|
||||||
|
media_content_type=mp.MediaType.APP,
|
||||||
|
media_content_id="pkg",
|
||||||
|
can_play=True,
|
||||||
|
can_expand=False,
|
||||||
|
domain="nvidia_shield", # G18 keyword of BrowseMediaSource only
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_redaction_uses_the_real_helper_module(self):
|
||||||
|
import homeassistant.helpers.redact as redact
|
||||||
|
import nsh.diagnostics as diagnostics
|
||||||
|
|
||||||
|
assert redact.REDACTED == "**REDACTED**"
|
||||||
|
# the module actually USES the helper from the real module path
|
||||||
|
assert diagnostics.async_redact_data is redact.async_redact_data
|
||||||
|
assert {"adb_host", "protocol_host"} <= diagnostics._TO_REDACT
|
||||||
|
assert diagnostics._REDACTED == redact.REDACTED
|
||||||
|
|
||||||
|
def test_no_production_module_imports_the_nonexistent_diagnostic_module(self):
|
||||||
|
"""t37 D2: `homeassistant.util.diagnostic` never existed."""
|
||||||
|
offenders = [
|
||||||
|
f"{path.name}:{node.lineno}"
|
||||||
|
for path in sorted(_SHIELD_DIR.glob("*.py"))
|
||||||
|
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8")))
|
||||||
|
if isinstance(node, ast.ImportFrom)
|
||||||
|
and node.module == "homeassistant.util.diagnostic"
|
||||||
|
]
|
||||||
|
assert offenders == []
|
||||||
|
|
||||||
|
def test_diagnostics_has_no_silent_import_fallback(self):
|
||||||
|
"""The redaction import is at module scope — loud, not swallowed."""
|
||||||
|
tree = ast.parse(
|
||||||
|
(_SHIELD_DIR / "diagnostics.py").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
caught = [
|
||||||
|
handler
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Try)
|
||||||
|
for handler in node.handlers
|
||||||
|
if isinstance(handler.type, ast.Name)
|
||||||
|
and handler.type.id == "ImportError"
|
||||||
|
]
|
||||||
|
assert caught == []
|
||||||
|
|
||||||
|
|
||||||
class TestBrowseMediaDirectory:
|
class TestBrowseMediaDirectory:
|
||||||
"""P4-2c: the app directory root and its launch path."""
|
"""P4-2c: the app directory root and its launch path."""
|
||||||
|
|
||||||
@@ -942,6 +1133,24 @@ class TestBrowseMediaDirectory:
|
|||||||
("com.netflix.ninja", "Netflix"),
|
("com.netflix.ninja", "Netflix"),
|
||||||
]
|
]
|
||||||
assert all(c.media_content_type == "app" for c in root.children)
|
assert all(c.media_content_type == "app" for c in root.children)
|
||||||
|
# t37 D1: a standard media_player.BrowseMedia tree — explicit
|
||||||
|
# media_content_id/-type at every level, the directory naming the class
|
||||||
|
# of its children, and NO media_source `domain` (that parameter belongs
|
||||||
|
# to media_source.models.BrowseMediaSource, which also builds the
|
||||||
|
# media_source:// ids).
|
||||||
|
assert root.media_class == "directory"
|
||||||
|
assert root.media_content_id == "apps"
|
||||||
|
assert root.media_content_type == "apps"
|
||||||
|
assert root.children_media_class == "app"
|
||||||
|
assert not hasattr(root, "domain")
|
||||||
|
assert [c.media_class for c in root.children] == ["app", "app"]
|
||||||
|
# ...and it serialises into the payload the frontend consumes
|
||||||
|
payload = root.as_dict()
|
||||||
|
assert payload["media_content_id"] == "apps"
|
||||||
|
assert payload["children_media_class"] == "app"
|
||||||
|
assert payload["children"][0]["media_content_id"] == "ar.tvplayer.tv"
|
||||||
|
assert payload["children"][0]["media_content_type"] == "app"
|
||||||
|
assert payload["children"][0]["can_play"] is True
|
||||||
|
|
||||||
async def test_root_falls_back_to_the_curated_default(self, modules):
|
async def test_root_falls_back_to_the_curated_default(self, modules):
|
||||||
player, _s, _i = make_player(modules) # no apps map configured
|
player, _s, _i = make_player(modules) # no apps map configured
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ _STUB_NAMES = (
|
|||||||
"homeassistant.core",
|
"homeassistant.core",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.entity_registry",
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
"homeassistant.helpers.selector",
|
"homeassistant.helpers.selector",
|
||||||
# Loaded execution runs beneath the stubs; never escape this file either.
|
# Loaded execution runs beneath the stubs; never escape this file either.
|
||||||
"nsh.helpers",
|
"nsh.helpers",
|
||||||
@@ -76,7 +77,30 @@ def _install_import_stubs() -> None:
|
|||||||
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
||||||
|
|
||||||
config_entries.ConfigFlow = ConfigFlow
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
config_entries.OptionsFlow = type("OptionsFlow", (), {})
|
class _BaseOptionsFlow:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# t29 G3b: the current options-flow API surface on the local stub
|
||||||
|
class _SuggestedMixin:
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class OptionsFlowWithReload(_SuggestedMixin):
|
||||||
|
def async_create_entry(self, *, title, data):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
config_entries.OptionsFlow = _BaseOptionsFlow
|
||||||
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
core = types.ModuleType("homeassistant.core")
|
core = types.ModuleType("homeassistant.core")
|
||||||
@@ -98,6 +122,10 @@ def _install_import_stubs() -> None:
|
|||||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
sys.modules["homeassistant.helpers"] = helpers
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||||
|
device_registry.async_get = lambda hass: types.SimpleNamespace(devices={})
|
||||||
|
helpers.device_registry = device_registry
|
||||||
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
selector.EntitySelector = lambda *a, **k: None
|
selector.EntitySelector = lambda *a, **k: None
|
||||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ _STUB_NAMES = (
|
|||||||
"homeassistant.const",
|
"homeassistant.const",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.entity_registry",
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
"homeassistant.helpers.selector",
|
"homeassistant.helpers.selector",
|
||||||
"nsh.helpers",
|
"nsh.helpers",
|
||||||
"nsh.config_flow",
|
"nsh.config_flow",
|
||||||
@@ -132,9 +133,55 @@ def _install_stubs() -> None:
|
|||||||
|
|
||||||
class OptionsFlow:
|
class OptionsFlow:
|
||||||
pass
|
pass
|
||||||
|
# t29 G3b: the current options-flow API surface on the local stub
|
||||||
|
class _SuggestedMixin:
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class OptionsFlowWithReload(_SuggestedMixin):
|
||||||
|
def async_create_entry(self, *, title, data):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# t29 G3a: the current config-entries API surface on the local stub
|
||||||
|
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
|
||||||
|
|
||||||
|
def _update_reload_and_abort(self, entry, **kwargs):
|
||||||
|
# `options` REPLACES the entry options (the real signature); the merge
|
||||||
|
# form is still accepted for older callers.
|
||||||
|
updates = dict(kwargs.get("options_updates") or {})
|
||||||
|
updates.update(kwargs.get("options") or {})
|
||||||
|
options = entry.options
|
||||||
|
for key, value in updates.items():
|
||||||
|
options[key] = value
|
||||||
|
if kwargs.get("unique_id"):
|
||||||
|
entry.unique_id = kwargs["unique_id"]
|
||||||
|
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||||
|
reason = kwargs.get("reason")
|
||||||
|
if reason is None:
|
||||||
|
reason = (
|
||||||
|
"reconfigure_successful"
|
||||||
|
if getattr(self, "source", None) == "reconfigure"
|
||||||
|
else "reauth_successful"
|
||||||
|
)
|
||||||
|
return self.async_abort(reason=reason)
|
||||||
|
|
||||||
|
ConfigFlow.async_update_reload_and_abort = _update_reload_and_abort
|
||||||
|
|
||||||
config_entries.ConfigFlow = ConfigFlow
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
config_entries.OptionsFlow = OptionsFlow
|
config_entries.OptionsFlow = OptionsFlow
|
||||||
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
@@ -167,6 +214,10 @@ def _install_stubs() -> None:
|
|||||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
sys.modules["homeassistant.helpers"] = helpers
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||||
|
device_registry.async_get = lambda hass: SimpleNamespace(devices={})
|
||||||
|
helpers.device_registry = device_registry
|
||||||
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
selector.EntitySelector = lambda *a, **k: None
|
selector.EntitySelector = lambda *a, **k: None
|
||||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,208 @@
|
|||||||
|
"""Run the Home-Assistant API-drift sweep (t37).
|
||||||
|
|
||||||
|
Skips cleanly when homeassistant is not installed — every other test file runs
|
||||||
|
against hand-written stubs, and this is the one check that must see the real
|
||||||
|
package. CI's `ha-api-drift` job installs it:
|
||||||
|
|
||||||
|
uvx --with homeassistant --with pytest-asyncio pytest tests/test_ha_api_drift.py -q
|
||||||
|
|
||||||
|
The sweep itself lives in ``tests/ha_api_drift.py`` (documented there).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ha_api_drift
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip(
|
||||||
|
"homeassistant",
|
||||||
|
reason="the API-drift sweep resolves our call sites against a real install",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The three t36 drifts plus the `options_updates` kwarg t35 removed — the
|
||||||
|
#: sweep must flag every one of them, or it cannot protect us from the next.
|
||||||
|
PRE_FIX_SOURCES = {
|
||||||
|
"d1_browse_media_source.py": (
|
||||||
|
"from homeassistant.components.media_player import BrowseMediaSource\n"
|
||||||
|
),
|
||||||
|
"d2_util_diagnostic.py": (
|
||||||
|
"from homeassistant.util.diagnostic import async_redact_data\n"
|
||||||
|
),
|
||||||
|
"d3_unawaited_discovery.py": (
|
||||||
|
"from homeassistant import config_entries\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"class PreFixFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
||||||
|
" async def async_step_zeroconf(self, discovery_info):\n"
|
||||||
|
" self._async_handle_discovery_without_unique_id()\n"
|
||||||
|
),
|
||||||
|
"t35_options_updates.py": (
|
||||||
|
"from homeassistant import config_entries\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"class PreFixReauthFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
||||||
|
" async def async_step_reauth_confirm(self, user_input=None):\n"
|
||||||
|
" self.async_update_reload_and_abort(\n"
|
||||||
|
" self.entry, options_updates={'protocol_enabled': True}\n"
|
||||||
|
" )\n"
|
||||||
|
" return self.async_abort(reason='reauth_successful')\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_installed_homeassistant():
|
||||||
|
"""Skip when sys.modules holds a test stub rather than an install."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
module = sys.modules["homeassistant"]
|
||||||
|
path = getattr(module, "__file__", None)
|
||||||
|
if path is None or not Path(path).exists():
|
||||||
|
pytest.skip("homeassistant in sys.modules is a test stub, not an install")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_api_drift_in_the_production_tree():
|
||||||
|
"""The gate: every HA import, kwarg and await matches the real package."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
report = ha_api_drift.scan()
|
||||||
|
|
||||||
|
assert report.findings == [], "\n" + str(report)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sweep_is_not_vacuous():
|
||||||
|
"""The counters prove the walk really covered the production tree."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
report = ha_api_drift.scan()
|
||||||
|
|
||||||
|
# Floors, not targets: the numbers live in ha_api_drift.FLOORS — one source
|
||||||
|
# of truth for this wrapper and the standalone entry point (t39 F1) — so a
|
||||||
|
# broken or mis-targeted walk fails here instead of looking green.
|
||||||
|
violations = report.floor_violations()
|
||||||
|
|
||||||
|
assert violations == [], "\n".join(violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sweep_flags_the_pre_fix_defects(tmp_path):
|
||||||
|
"""Red proof: the t36 drifts and the options_updates kwarg are all caught."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
for name, source in PRE_FIX_SOURCES.items():
|
||||||
|
(tmp_path / name).write_text(source, encoding="utf-8")
|
||||||
|
|
||||||
|
report = ha_api_drift.scan(tmp_path)
|
||||||
|
|
||||||
|
found = {(finding.path, finding.kind) for finding in report.findings}
|
||||||
|
assert found == {
|
||||||
|
("d1_browse_media_source.py", "broken-import"),
|
||||||
|
("d2_util_diagnostic.py", "broken-import"),
|
||||||
|
("d3_unawaited_discovery.py", "missing-await"),
|
||||||
|
("t35_options_updates.py", "unknown-kwarg"),
|
||||||
|
}, "\n" + str(report)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sweep_accepts_the_fixed_forms(tmp_path):
|
||||||
|
"""The same call sites written the way production writes them are clean."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
(tmp_path / "fixed.py").write_text(
|
||||||
|
"from homeassistant.components.media_player import BrowseMedia\n"
|
||||||
|
"from homeassistant.helpers.redact import async_redact_data\n"
|
||||||
|
"from homeassistant import config_entries\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"class FixedFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
||||||
|
" async def async_step_zeroconf(self, discovery_info):\n"
|
||||||
|
" await self._async_handle_discovery_without_unique_id()\n"
|
||||||
|
"\n"
|
||||||
|
" def finish(self):\n"
|
||||||
|
" return self.async_update_reload_and_abort(\n"
|
||||||
|
" self.entry, options={'protocol_enabled': True}\n"
|
||||||
|
" )\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
report = ha_api_drift.scan(tmp_path)
|
||||||
|
|
||||||
|
assert report.findings == [], "\n" + str(report)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_drift_module(*paths):
|
||||||
|
"""Run the standalone entry point in this (HA-bearing) interpreter."""
|
||||||
|
return subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(Path(ha_api_drift.__file__).resolve()),
|
||||||
|
*[str(path) for path in paths],
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_standalone_entry_point_passes_on_the_production_tree():
|
||||||
|
"""F1: the CLI is the sweep, with the same verdict on the real tree."""
|
||||||
|
result = _run_drift_module()
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert "no Home-Assistant API drift" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_standalone_entry_point_fails_on_an_empty_directory(tmp_path):
|
||||||
|
"""t39 F1: zeros must not exit 0 — the floors apply to the CLI too."""
|
||||||
|
result = _run_drift_module(tmp_path)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "0 modules" in result.stdout # the counter that used to look green
|
||||||
|
assert "FLOOR:" in result.stderr
|
||||||
|
assert "drift sweep FAILED" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_standalone_entry_point_fails_on_a_missing_path(tmp_path):
|
||||||
|
"""A mis-specified path is a failure, not a silent zero-module sweep."""
|
||||||
|
result = _run_drift_module(tmp_path / "not-a-directory")
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "bad-path" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_floor_constants_are_duplicated_in_this_wrapper():
|
||||||
|
"""F1: the floors come from ha_api_drift.FLOORS — nowhere else."""
|
||||||
|
source = Path(__file__).read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
|
||||||
|
local_ints = [
|
||||||
|
target.id
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.Assign)
|
||||||
|
for target in node.targets
|
||||||
|
if isinstance(target, ast.Name)
|
||||||
|
and isinstance(node.value, ast.Constant)
|
||||||
|
and isinstance(node.value.value, int)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert local_ints == [], f"floor-like constants belong in ha_api_drift: {local_ints}"
|
||||||
|
assert "floor_violations" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_declared_python_floor_never_lags_homeassistant():
|
||||||
|
"""F2: the CI guard's floor is HA's own Requires-Python, not a guess."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
import importlib.metadata as metadata
|
||||||
|
|
||||||
|
declared = metadata.metadata("homeassistant")["Requires-Python"]
|
||||||
|
parsed = ha_api_drift.parse_requires_python(declared)
|
||||||
|
|
||||||
|
assert parsed is not None, f"unparsable Requires-Python: {declared!r}"
|
||||||
|
assert tuple(parsed) <= ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR, (
|
||||||
|
f"tests/ha_api_drift.py declares {ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR} "
|
||||||
|
f"but the installed homeassistant requires {declared} — raise the constant "
|
||||||
|
f"and the setup-python version in .gitea/workflows/test.yml"
|
||||||
|
)
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""tests/test_init.py — the t30 G6 structure test file.
|
||||||
|
|
||||||
|
Covers the integration-level surfaces HA's testing-structure doc names for
|
||||||
|
``test_init.py``: domain async_setup (empty config → no entries), the
|
||||||
|
always-on adb_command registration, and the YAML seed parsing units plus
|
||||||
|
the import step's duplicate guard and interactive routing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Scoped-stub hygiene (mirrors tests/test_config_flow_pairing.py's
|
||||||
|
# scoped_stubs pattern): the config-flow files install their own stub faces
|
||||||
|
# and pop them per-test; test_init's runs must never leave a hub-stub set
|
||||||
|
# behind that a later file's loader would bind against.
|
||||||
|
_INIT_STUB_NAMES = (
|
||||||
|
"voluptuous",
|
||||||
|
"homeassistant",
|
||||||
|
"homeassistant.config_entries",
|
||||||
|
"homeassistant.core",
|
||||||
|
"homeassistant.helpers",
|
||||||
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
|
"homeassistant.helpers.selector",
|
||||||
|
# the G8 lazy import in async_setup registers this platform module —
|
||||||
|
# without popping it the install() guard would skip the rebuild below
|
||||||
|
"homeassistant.components.media_player",
|
||||||
|
"nsh.helpers",
|
||||||
|
"nsh.config_flow",
|
||||||
|
"nsh.init",
|
||||||
|
"nsh.media_player",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _integration_module():
|
||||||
|
"""HA stubs + the integration __init__ module under nsh.init."""
|
||||||
|
snapshot = {name: sys.modules.get(name) for name in _INIT_STUB_NAMES}
|
||||||
|
entity_stublib.install()
|
||||||
|
if "nsh.init" not in sys.modules:
|
||||||
|
import conftest
|
||||||
|
|
||||||
|
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
||||||
|
yield sys.modules["nsh.init"]
|
||||||
|
for name in _INIT_STUB_NAMES:
|
||||||
|
original = snapshot[name]
|
||||||
|
if original is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = original
|
||||||
|
|
||||||
|
|
||||||
|
class _SeedEntriesStub:
|
||||||
|
"""config-entries shape for flow seeding tests: entries + flow records."""
|
||||||
|
|
||||||
|
def __init__(self, entries=()):
|
||||||
|
self._entries = list(entries)
|
||||||
|
self.flows = [] # (domain, context, data) records of flow.async_init
|
||||||
|
|
||||||
|
def async_entries(self):
|
||||||
|
return list(self._entries)
|
||||||
|
|
||||||
|
class _Flows:
|
||||||
|
def __init__(self, holder):
|
||||||
|
self._holder = holder
|
||||||
|
|
||||||
|
async def async_init(self, domain, context=None, data=None):
|
||||||
|
self._holder.flows.append((domain, context, data))
|
||||||
|
return {"type": "form", "items": {}}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def flow(self):
|
||||||
|
return self._Flows(self)
|
||||||
|
|
||||||
|
|
||||||
|
class _SeedHass(SimpleNamespace):
|
||||||
|
"""Minimal hass for async_setup: config entries, services, tasks."""
|
||||||
|
|
||||||
|
def __init__(self, entries=()):
|
||||||
|
config_entries = _SeedEntriesStub()
|
||||||
|
config_entries.updating_entry_calls = []
|
||||||
|
self._services = {("nvidia_shield", "adb_command"): None}
|
||||||
|
super().__init__(
|
||||||
|
config_entries=config_entries,
|
||||||
|
scheduled_tasks=[],
|
||||||
|
services=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
def async_register(self, domain, service, func, schema=None):
|
||||||
|
self._services[(domain, service)] = (func, schema)
|
||||||
|
|
||||||
|
def has_service(self, domain, service):
|
||||||
|
return (domain, service) in self._services
|
||||||
|
|
||||||
|
def async_create_task(self, coro):
|
||||||
|
task = asyncio.ensure_future(coro)
|
||||||
|
self.scheduled_tasks.append(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
class TestDomainSetup:
|
||||||
|
"""async_setup: empty config, service registration, no entries."""
|
||||||
|
|
||||||
|
async def test_empty_config_creates_nothing(self, _integration_module):
|
||||||
|
hass = _SeedHass(entries=[])
|
||||||
|
|
||||||
|
result = await _integration_module.async_setup(hass, {})
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert hass.config_entries.flows == [] # nothing seeded
|
||||||
|
assert hass.services.has_service("nvidia_shield", "adb_command")
|
||||||
|
|
||||||
|
|
||||||
|
class TestYamlSeedBlocks:
|
||||||
|
"""_yaml_seed_blocks: the optional domain key → normalized seeds."""
|
||||||
|
|
||||||
|
def test_block_normalized(self, _integration_module):
|
||||||
|
from nsh.const import CONF_ADB_HOST, CONF_ADB_PORT, DEFAULT_ADB_PORT
|
||||||
|
|
||||||
|
blocks = _integration_module._yaml_seed_blocks(
|
||||||
|
[{"name": "Living", "adb_host": "10.0.0.31"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
seed = blocks[0]
|
||||||
|
assert seed["name"] == "Living"
|
||||||
|
assert seed[CONF_ADB_HOST] == "10.0.0.31"
|
||||||
|
assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT
|
||||||
|
assert seed["adb_enabled"] is True
|
||||||
|
assert "unique_id" not in seed # optional key only
|
||||||
|
|
||||||
|
def test_single_block_and_protocol_hint(self, _integration_module):
|
||||||
|
blocks = _integration_module._yaml_seed_blocks(
|
||||||
|
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
# protocol fields are prefill hints ONLY (pairing stays interactive)
|
||||||
|
assert blocks[0]["protocol_host"] == "shield.local"
|
||||||
|
assert blocks[0]["adb_port"] == 5000
|
||||||
|
|
||||||
|
def test_list_and_single_block_shapes(self, _integration_module):
|
||||||
|
single = _integration_module._yaml_seed_blocks({"name": "A"})
|
||||||
|
listed = _integration_module._yaml_seed_blocks([{"name": "B"}, {"name": "C"}])
|
||||||
|
|
||||||
|
assert len(single) == 1 and len(listed) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeeding:
|
||||||
|
"""async_setup seeds SOURCE_IMPORT flows for shaped blocks only."""
|
||||||
|
|
||||||
|
async def test_shaped_block_seeds_a_flow(self, _integration_module):
|
||||||
|
hass = _SeedHass(entries=[])
|
||||||
|
|
||||||
|
await _integration_module.async_setup(
|
||||||
|
hass, {"nvidia_shield": [{"name": "R", "adb_host": "10.0.0.5"}]}
|
||||||
|
)
|
||||||
|
if hass.scheduled_tasks:
|
||||||
|
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
assert len(hass.config_entries.flows) == 1
|
||||||
|
domain, context, data = hass.config_entries.flows[0]
|
||||||
|
assert domain == "nvidia_shield"
|
||||||
|
assert context["source"] == "import"
|
||||||
|
assert data["adb_host"] == "10.0.0.5"
|
||||||
|
|
||||||
|
async def test_hostless_block_never_seeds(self, _integration_module):
|
||||||
|
hass = _SeedHass(entries=[])
|
||||||
|
|
||||||
|
result = await _integration_module.async_setup(
|
||||||
|
hass, {"nvidia_shield": [{"name": "X"}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert hass.config_entries.flows == [] # hostless → warning only
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportStep:
|
||||||
|
"""async_step_import: duplicate guards + interactive routing."""
|
||||||
|
|
||||||
|
def _flow(self, entries):
|
||||||
|
# the flow module loads under these stubs and the base plumbing is
|
||||||
|
# injected locally — importing test_standalone_setup here would
|
||||||
|
# leak its local stub set into later test files.
|
||||||
|
from nsh.config_flow import NvidiaShieldConfigFlow
|
||||||
|
|
||||||
|
hass = SimpleNamespace(
|
||||||
|
config_entries=_SeedEntriesStub(entries),
|
||||||
|
)
|
||||||
|
hass.config_entries.flows = []
|
||||||
|
flow = NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = hass
|
||||||
|
flow.context = {}
|
||||||
|
# ConfigFlow base attrs the stub class doesn't implement (t7 shape):
|
||||||
|
flow.async_set_unique_id = None # set below
|
||||||
|
flow._collected_unique_id = None
|
||||||
|
|
||||||
|
async def set_unique_id(uid):
|
||||||
|
flow._collected_unique_id = uid
|
||||||
|
|
||||||
|
flow.async_set_unique_id = set_unique_id
|
||||||
|
|
||||||
|
def abort_if_unique_id_configured(*args, **kwargs):
|
||||||
|
for entry in hass.config_entries.async_entries():
|
||||||
|
if entry.unique_id == flow._collected_unique_id:
|
||||||
|
from nsh.config_flow import AbortIfConfiguredError
|
||||||
|
|
||||||
|
raise AbortIfConfiguredError(f"{entry.unique_id}")
|
||||||
|
|
||||||
|
flow._abort_if_unique_id_configured = abort_if_unique_id_configured
|
||||||
|
# the base ConfigFlow shows forms through the stub plumbing (matching
|
||||||
|
# how the standalone flow tests install it):
|
||||||
|
flow.async_show_form = lambda **kwargs: {
|
||||||
|
"type": "form",
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {
|
||||||
|
"type": "create_entry",
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
flow.async_abort = lambda **kwargs: {"type": "abort", **kwargs}
|
||||||
|
flow._async_current_entries = lambda: list(
|
||||||
|
hass.config_entries.async_entries()
|
||||||
|
)
|
||||||
|
return flow
|
||||||
|
|
||||||
|
async def test_ui_configured_host_never_re_seeded(self):
|
||||||
|
from nsh.const import CONF_ADB_HOST
|
||||||
|
|
||||||
|
existing = SimpleNamespace(
|
||||||
|
entry_id="ui1",
|
||||||
|
unique_id=None,
|
||||||
|
options={CONF_ADB_HOST: "10.0.0.31"},
|
||||||
|
)
|
||||||
|
# options live on the entry (conf zeroconf guard reads them)
|
||||||
|
flow = self._flow([existing])
|
||||||
|
|
||||||
|
result = await flow.async_step_import(
|
||||||
|
{"name": "A", "adb_host": "10.0.0.31"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"type": "abort", "reason": "already_configured"}
|
||||||
|
|
||||||
|
async def test_virtual_unique_id_blocks_reseed(self):
|
||||||
|
flow_holder = SimpleNamespace(
|
||||||
|
entry_id="seed1",
|
||||||
|
unique_id="yaml:Living:10.0.0.31",
|
||||||
|
options={},
|
||||||
|
)
|
||||||
|
flow = self._flow([flow_holder])
|
||||||
|
|
||||||
|
result = await flow.async_step_import(
|
||||||
|
{"name": "Living", "adb_host": "10.0.0.31"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"type": "abort", "reason": "already_configured"}
|
||||||
|
|
||||||
|
async def test_fresh_block_routes_into_the_user_flow(self):
|
||||||
|
flow = self._flow([])
|
||||||
|
|
||||||
|
result = await flow.async_step_import(
|
||||||
|
{"name": "Fresh", "adb_host": "10.0.0.99"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# interactive pairing — the pre-filled user step, one visit
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "user"
|
||||||
+403
-17
@@ -8,13 +8,21 @@ migration validity for v2 entries without the field.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio # noqa: F401 (async steps need the event loop)
|
import asyncio # noqa: F401 (async steps need the event loop)
|
||||||
|
import gc
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path # noqa: E402 (F1 test key staging)
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import voluptuous_mirror as vol_mirror
|
||||||
from conftest import _SHIELD_DIR, _load_module
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
from nsh.const import (
|
from nsh.const import (
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
CONF_PROTOCOL_ENABLED,
|
CONF_PROTOCOL_ENABLED,
|
||||||
CONF_PROTOCOL_HOST,
|
CONF_PROTOCOL_HOST,
|
||||||
@@ -28,6 +36,7 @@ _STUB_NAMES = (
|
|||||||
"homeassistant.const",
|
"homeassistant.const",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.entity_registry",
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
"homeassistant.helpers.selector",
|
"homeassistant.helpers.selector",
|
||||||
"nsh.helpers",
|
"nsh.helpers",
|
||||||
"nsh.config_flow",
|
"nsh.config_flow",
|
||||||
@@ -35,15 +44,36 @@ _STUB_NAMES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoveryDedupAbortError(Exception):
|
||||||
|
"""Stands in for data_entry_flow.AbortFlow inside the discovery stub.
|
||||||
|
|
||||||
|
HA's `ConfigFlow._async_handle_discovery_without_unique_id` RAISES on a
|
||||||
|
duplicate (the flow manager turns that into an abort result); modelling it
|
||||||
|
as a raise is what makes an un-awaited call fail loudly here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, reason):
|
||||||
|
super().__init__(reason)
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
def _install_stubs() -> None:
|
def _install_stubs() -> None:
|
||||||
"""Import-time stand-ins (idempotent; same shape as the pairing tests)."""
|
"""Import-time stand-ins (idempotent; same shape as the pairing tests).
|
||||||
if sys.modules.get("voluptuous") is not None:
|
|
||||||
|
t30: the guard rebuilds whenever the module-level holder is missing —
|
||||||
|
a later file's scoped teardown may pop the shared stubs this file's
|
||||||
|
tests rely on (the flow-class surfaces live on the local base which we
|
||||||
|
MUST rebind against)."""
|
||||||
|
if sys.modules.get("voluptuous") is not None and hasattr(
|
||||||
|
sys.modules.get(__name__), "_zc_holder"
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
vol = types.ModuleType("voluptuous")
|
vol = types.ModuleType("voluptuous")
|
||||||
vol.Required = lambda key, default=None: key
|
# t33 F-A: a faithful mirror — the default stays ON the key, so a schema
|
||||||
vol.Optional = lambda key, default=None: key
|
# whose host default was dropped FAILS here instead of looking identical
|
||||||
vol.Schema = lambda schema: schema
|
# to one that carries it (the old bare-key stub hid exactly that bug).
|
||||||
|
vol_mirror.install(vol)
|
||||||
sys.modules["voluptuous"] = vol
|
sys.modules["voluptuous"] = vol
|
||||||
|
|
||||||
ha = types.ModuleType("homeassistant")
|
ha = types.ModuleType("homeassistant")
|
||||||
@@ -58,12 +88,16 @@ def _install_stubs() -> None:
|
|||||||
kwargs.pop("domain", None)
|
kwargs.pop("domain", None)
|
||||||
super().__init_subclass__(**kwargs)
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
def async_show_form(
|
||||||
|
self, *, step_id, data_schema=None, errors=None,
|
||||||
|
description_placeholders=None,
|
||||||
|
):
|
||||||
return {
|
return {
|
||||||
"type": "form",
|
"type": "form",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"errors": dict(errors or {}),
|
"errors": dict(errors or {}),
|
||||||
"data_schema": data_schema,
|
"data_schema": data_schema,
|
||||||
|
"description_placeholders": dict(description_placeholders or {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_create_entry(self, *, title, data, options=None):
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
@@ -82,6 +116,24 @@ def _install_stubs() -> None:
|
|||||||
def _async_current_entries(self):
|
def _async_current_entries(self):
|
||||||
return _zc_holder.entries
|
return _zc_holder.entries
|
||||||
|
|
||||||
|
def _async_in_progress(self, include_uninitialized=False, **kwargs):
|
||||||
|
return _zc_holder.in_progress
|
||||||
|
|
||||||
|
# t37 D3: HA's own helper, modelled faithfully — a COROUTINE that
|
||||||
|
# dedups uniqueness-less discovery by raising. An un-awaited call
|
||||||
|
# therefore does nothing at all, which is exactly the production bug
|
||||||
|
# this replaces.
|
||||||
|
async def _async_handle_discovery_without_unique_id(self):
|
||||||
|
_zc_holder.discovery_without_unique_id_calls += 1
|
||||||
|
if getattr(self, "_unique_id", None) is not None:
|
||||||
|
return
|
||||||
|
if self._async_current_entries():
|
||||||
|
raise DiscoveryDedupAbortError("already_configured")
|
||||||
|
await self.async_set_unique_id(_zc_holder.discovery_unique_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
if self._async_in_progress(include_uninitialized=True):
|
||||||
|
raise DiscoveryDedupAbortError("already_in_progress")
|
||||||
|
|
||||||
class OptionsFlow:
|
class OptionsFlow:
|
||||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||||
return {
|
return {
|
||||||
@@ -94,9 +146,33 @@ def _install_stubs() -> None:
|
|||||||
def async_create_entry(self, *, title, data):
|
def async_create_entry(self, *, title, data):
|
||||||
return {"type": "create_entry", "title": title, "data": data}
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
# t29 G3b: the current options-flow API surface on the local stub
|
||||||
|
class _SuggestedMixin:
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class OptionsFlowWithReload(_SuggestedMixin):
|
||||||
|
def async_create_entry(self, *, title, data):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
# Shared holder so tests can swap the current-entry list through the
|
# Shared holder so tests can swap the current-entry list through the
|
||||||
# module object (the stub closure reads this same object).
|
# module object (the stub closure reads this same object).
|
||||||
_zc_holder = types.SimpleNamespace(entries=[])
|
_zc_holder = types.SimpleNamespace(
|
||||||
|
entries=[],
|
||||||
|
in_progress=[],
|
||||||
|
discovery_unique_id="default_discovery_unique_id",
|
||||||
|
discovery_without_unique_id_calls=0,
|
||||||
|
)
|
||||||
sys.modules[__name__]._zc_holder = _zc_holder
|
sys.modules[__name__]._zc_holder = _zc_holder
|
||||||
|
|
||||||
class AbortIfConfiguredError(Exception):
|
class AbortIfConfiguredError(Exception):
|
||||||
@@ -104,6 +180,8 @@ def _install_stubs() -> None:
|
|||||||
|
|
||||||
config_entries.ConfigFlow = ConfigFlow
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
config_entries.OptionsFlow = OptionsFlow
|
config_entries.OptionsFlow = OptionsFlow
|
||||||
|
OptionsFlowWithReload.async_show_form = OptionsFlow.async_show_form # form plumbing
|
||||||
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
@@ -131,6 +209,10 @@ def _install_stubs() -> None:
|
|||||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
sys.modules["homeassistant.helpers"] = helpers
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||||
|
device_registry.async_get = lambda hass: SimpleNamespace(devices={})
|
||||||
|
helpers.device_registry = device_registry
|
||||||
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
selector.EntitySelector = lambda *a, **k: None
|
selector.EntitySelector = lambda *a, **k: None
|
||||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||||
@@ -380,7 +462,8 @@ class TestAppIconColumn:
|
|||||||
async def test_two_column_line_stores_the_icon(self, tmp_path):
|
async def test_two_column_line_stores_the_icon(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
entry = SimpleNamespace(options={}, title="Shield")
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
flow = module.NvidiaShieldOptionsFlow()
|
||||||
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
@@ -393,7 +476,8 @@ class TestAppIconColumn:
|
|||||||
async def test_single_column_map_stays_valid(self, tmp_path):
|
async def test_single_column_map_stays_valid(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
entry = SimpleNamespace(options={}, title="Shield")
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
flow = module.NvidiaShieldOptionsFlow()
|
||||||
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
@@ -422,7 +506,8 @@ class TestAppIconColumn:
|
|||||||
async def test_malformed_icon_invalidates_the_row(self, tmp_path):
|
async def test_malformed_icon_invalidates_the_row(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
entry = SimpleNamespace(options={}, title="Shield")
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
flow = module.NvidiaShieldOptionsFlow()
|
||||||
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
@@ -441,7 +526,8 @@ class TestChannelEntryOptions:
|
|||||||
|
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
entry = DefaultEntry(options=dict(options) if options else {})
|
entry = DefaultEntry(options=dict(options) if options else {})
|
||||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
flow = module.NvidiaShieldOptionsFlow()
|
||||||
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||||
hass = FakeHass(str(tmp_path))
|
hass = FakeHass(str(tmp_path))
|
||||||
flow.hass = hass
|
flow.hass = hass
|
||||||
flow._options = dict(options) if options else {}
|
flow._options = dict(options) if options else {}
|
||||||
@@ -481,6 +567,96 @@ class TestChannelEntryOptions:
|
|||||||
assert result["errors"] == {"base": "invalid_digit_delay"}
|
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestUniqueIdSourceG2:
|
||||||
|
"""t29 G2: no unique_id equals the androidtv entity_id anymore."""
|
||||||
|
|
||||||
|
def test_entity_unique_id_fallback_is_gone(self):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield"
|
||||||
|
flow_src = (base / "config_flow.py").read_text()
|
||||||
|
# the historic renameable fallback is gone from the user step
|
||||||
|
assert "async_set_unique_id(androidtv_entity)" not in flow_src
|
||||||
|
# the documented replacement: a device-MAC derive, else no unique_id
|
||||||
|
assert "_unique_id_from_atv_device" in flow_src
|
||||||
|
|
||||||
|
def test_entity_rename_never_moves_the_unique_id(self):
|
||||||
|
"""The MAC-derived id is rename-proof; a MAC-less entity has none."""
|
||||||
|
|
||||||
|
|
||||||
|
module = _form_module()
|
||||||
|
# a registered atv entity whose device carries a MAC
|
||||||
|
entity = SimpleNamespace(domain="media_player", device_id="dev1", platform="lgtv")
|
||||||
|
device = SimpleNamespace(
|
||||||
|
connections=[("mac", None), ("mac", "aa:bb:cc:dd:ee:ff")]
|
||||||
|
)
|
||||||
|
er_stub = module.er
|
||||||
|
er_stub.async_get = lambda hass: SimpleNamespace(
|
||||||
|
entities={"media_player.shield": entity}
|
||||||
|
)
|
||||||
|
class _DevRegistry:
|
||||||
|
def __init__(self, by_id):
|
||||||
|
self._by_id = by_id
|
||||||
|
|
||||||
|
def async_get(self, device_id):
|
||||||
|
return self._by_id.get(device_id)
|
||||||
|
|
||||||
|
module.dr.async_get = lambda hass: _DevRegistry({"dev1": device})
|
||||||
|
|
||||||
|
# the resolved unique_id is the MAC — not the entity_id
|
||||||
|
uid = module._unique_id_from_atv_device(
|
||||||
|
FakeHass("/tmp"), "media_player.shield"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert uid == "aa:bb:cc:dd:ee:ff"
|
||||||
|
|
||||||
|
# renaming the entity does not affect the resolved value
|
||||||
|
er_stub.async_get = lambda hass: SimpleNamespace(
|
||||||
|
entities={"media_player.renamed": entity}
|
||||||
|
)
|
||||||
|
uid_renamed = module._unique_id_from_atv_device(
|
||||||
|
FakeHass("/tmp"), "media_player.renamed"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert uid_renamed == "aa:bb:cc:dd:ee:ff" # stable across renames
|
||||||
|
|
||||||
|
def test_macless_entity_yields_no_unique_id(self):
|
||||||
|
|
||||||
|
|
||||||
|
module = _form_module()
|
||||||
|
er_stub = module.er
|
||||||
|
er_stub.async_get = lambda hass: SimpleNamespace(entities={}) # unregistered
|
||||||
|
assert (
|
||||||
|
module._unique_id_from_atv_device(FakeHass("/tmp"), "media_player.x")
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestListenerComboGone:
|
||||||
|
"""t29 G3a: the deprecated listener+reload combo is gone (grep pins)."""
|
||||||
|
def test_no_update_listener_remains_on_source_and_flow_paths(self):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield"
|
||||||
|
init_src = (base / "__init__.py").read_text()
|
||||||
|
flow_src = (base / "config_flow.py").read_text()
|
||||||
|
# no update listener registration remains on the integration side
|
||||||
|
assert "add_update_listener" not in init_src
|
||||||
|
assert "_async_update_listener" not in init_src
|
||||||
|
# the flows no longer schedule_reload (zeroconf host update / reauth)
|
||||||
|
assert "async_schedule_reload" not in flow_src
|
||||||
|
# the current reauth contract is in place
|
||||||
|
assert "async_update_reload_and_abort" in flow_src
|
||||||
|
assert "_get_reauth_entry" in flow_src
|
||||||
|
|
||||||
|
def test_reauth_success_reloads_via_the_new_api(self):
|
||||||
|
"""reload-on-reauth-success passes through the new helper."""
|
||||||
|
# the stub records a scheduled reload on the reauth path
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
assert entity_stublib is not None # the stub API exists (config flow class)
|
||||||
|
|
||||||
|
|
||||||
class TestLegacyUniqueIdMigration:
|
class TestLegacyUniqueIdMigration:
|
||||||
"""P5-1: one-time legacy androidtv-entity-id unique_id migration."""
|
"""P5-1: one-time legacy androidtv-entity-id unique_id migration."""
|
||||||
|
|
||||||
@@ -631,6 +807,206 @@ class TestLegacyUniqueIdMigration:
|
|||||||
assert hass.config_entries.updating_entry_calls == [] # untouched
|
assert hass.config_entries.updating_entry_calls == [] # untouched
|
||||||
|
|
||||||
|
|
||||||
|
class TestIpChangeSelfHealF1:
|
||||||
|
"""t32 F1 (HIGH): the poller factory re-reads entry.options at call
|
||||||
|
time, so the NEXT built poller targets the NEW address after a
|
||||||
|
zeroconf IP change (the audit-sanctioned factory re-read option)."""
|
||||||
|
|
||||||
|
def _adb_entry(self, host="192.168.1.50"):
|
||||||
|
return SimpleNamespace(
|
||||||
|
entry_id="e1",
|
||||||
|
title="Shield",
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={
|
||||||
|
CONF_ADB_ENABLED: True,
|
||||||
|
CONF_ADB_HOST: host,
|
||||||
|
CONF_ADB_PORT: 5555,
|
||||||
|
},
|
||||||
|
runtime_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _hass_with_key(self, tmp_path):
|
||||||
|
import conftest
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.install()
|
||||||
|
if "nsh.init" not in sys.modules:
|
||||||
|
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
||||||
|
init_module = sys.modules["nsh.init"]
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
key_dir = Path(hass.config.config_dir) / ".storage" / "nvidia_shield"
|
||||||
|
key_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(key_dir / "adbkey").write_text("test-key")
|
||||||
|
return init_module, hass
|
||||||
|
|
||||||
|
async def test_built_poller_targets_the_new_address(self, tmp_path):
|
||||||
|
"""The delivered self-heal: after the zeroconf host update the
|
||||||
|
NEXT built poller targets the new address (no reload involved)."""
|
||||||
|
init_module, hass = self._hass_with_key(tmp_path)
|
||||||
|
entry = self._adb_entry()
|
||||||
|
hass.config_entries.entries = [entry]
|
||||||
|
|
||||||
|
factory = await init_module._build_poller_factory(hass, entry)
|
||||||
|
assert factory is not None
|
||||||
|
|
||||||
|
first = factory()
|
||||||
|
assert first is not None
|
||||||
|
# AdbClientBase exposes the connection config as a dict (t27 F-NEW3)
|
||||||
|
assert first.config["host"] == "192.168.1.50"
|
||||||
|
|
||||||
|
# the config-flow zeroconf path updates the entry's hosts via
|
||||||
|
# async_update_entry — the NEXT built poller targets the NEW address
|
||||||
|
entry.options = {
|
||||||
|
**entry.options,
|
||||||
|
CONF_ADB_HOST: "192.168.1.77",
|
||||||
|
CONF_PROTOCOL_HOST: "192.168.1.77",
|
||||||
|
}
|
||||||
|
|
||||||
|
second = factory()
|
||||||
|
assert second is not None
|
||||||
|
assert second.config["host"] == "192.168.1.77"
|
||||||
|
|
||||||
|
async def test_unresolvable_call_stays_passive(self, tmp_path):
|
||||||
|
"""A momentary unresolvable host yields None (passive cycle) — the
|
||||||
|
watchdog's existing None handling stays intact."""
|
||||||
|
init_module, hass = self._hass_with_key(tmp_path)
|
||||||
|
entry = self._adb_entry(host="10.255.255.1")
|
||||||
|
|
||||||
|
factory = await init_module._build_poller_factory(hass, entry)
|
||||||
|
assert factory is not None
|
||||||
|
|
||||||
|
with mock.patch("nsh.init.resolve_adb_config", return_value=None):
|
||||||
|
assert factory() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMacLessDiscoveryF2:
|
||||||
|
"""t32 F2 (rider): MAC-less zeroconf discovery records the
|
||||||
|
uniqueness-less discovery and can't stack a duplicate prefill."""
|
||||||
|
|
||||||
|
def _discovery(self, host="192.168.1.50", props=None):
|
||||||
|
return SimpleNamespace(
|
||||||
|
ip_address=host, host=host, properties=props or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _holder(self):
|
||||||
|
return sys.modules[__name__]._zc_holder
|
||||||
|
|
||||||
|
async def test_mac_less_discovery_awaits_the_helper(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
self._holder().discovery_without_unique_id_calls = 0
|
||||||
|
|
||||||
|
result = await flow.async_step_zeroconf(self._discovery())
|
||||||
|
|
||||||
|
assert result["type"] == "form" # prefill still offered once
|
||||||
|
# t37 D3: only an AWAITED coroutine body runs — before the fix this
|
||||||
|
# counter stayed at 0 and Python warned "coroutine ... was never
|
||||||
|
# awaited", while HA's dedup never happened
|
||||||
|
assert self._holder().discovery_without_unique_id_calls == 1
|
||||||
|
# the helper claims HA's discovery sentinel for the flow
|
||||||
|
assert flow._unique_id == self._holder().discovery_unique_id
|
||||||
|
|
||||||
|
async def test_mac_less_discovery_with_entries_aborts(self, tmp_path):
|
||||||
|
"""Real contract: any entry for the handler → already_configured."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
entry_id="e1",
|
||||||
|
unique_id=None, # MAC-less entry
|
||||||
|
options={CONF_PROTOCOL_HOST: "192.168.1.50"},
|
||||||
|
title="Shield",
|
||||||
|
)
|
||||||
|
self._holder().entries[:] = [entry]
|
||||||
|
|
||||||
|
with pytest.raises(DiscoveryDedupAbortError) as raised:
|
||||||
|
await flow.async_step_zeroconf(self._discovery())
|
||||||
|
|
||||||
|
assert raised.value.reason == "already_configured"
|
||||||
|
# the entries list is untouched: one-entry outcome
|
||||||
|
assert len(self._holder().entries) == 1
|
||||||
|
|
||||||
|
async def test_mac_less_discovery_while_another_flow_runs_aborts(self, tmp_path):
|
||||||
|
"""The other half of the real contract: one flow at a time."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
self._holder().in_progress[:] = [{"flow_id": "other"}]
|
||||||
|
|
||||||
|
with pytest.raises(DiscoveryDedupAbortError) as raised:
|
||||||
|
await flow.async_step_zeroconf(self._discovery())
|
||||||
|
|
||||||
|
assert raised.value.reason == "already_in_progress"
|
||||||
|
|
||||||
|
async def test_mac_less_discovery_leaves_no_un_awaited_coroutine(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
"""t37 D3: the helper must never be left un-awaited."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
await flow.async_step_zeroconf(self._discovery())
|
||||||
|
del flow
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
never_awaited = [
|
||||||
|
str(w.message) for w in caught if "never awaited" in str(w.message)
|
||||||
|
]
|
||||||
|
assert never_awaited == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeedBlockShapeF4:
|
||||||
|
"""t32 F4 (rider): protocol-only seed blocks don't force ADB on."""
|
||||||
|
|
||||||
|
def test_protocol_only_block_keeps_adb_off(self):
|
||||||
|
import conftest
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.install()
|
||||||
|
if "nsh.init" not in sys.modules:
|
||||||
|
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
||||||
|
init_module = sys.modules["nsh.init"]
|
||||||
|
|
||||||
|
blocks = init_module._yaml_seed_blocks(
|
||||||
|
{"name": "Proto", "protocol_host": "shield.local"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert blocks[0]["adb_enabled"] is False
|
||||||
|
assert blocks[0]["adb_host"] == ""
|
||||||
|
|
||||||
|
def test_adb_block_keeps_adb_on(self):
|
||||||
|
import conftest
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.install()
|
||||||
|
if "nsh.init" not in sys.modules:
|
||||||
|
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
||||||
|
init_module = sys.modules["nsh.init"]
|
||||||
|
|
||||||
|
blocks = init_module._yaml_seed_blocks(
|
||||||
|
{"name": "Adb", "adb_host": "10.0.0.31"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert blocks[0]["adb_enabled"] is True
|
||||||
|
|
||||||
|
async def test_protocol_only_import_prefills_adb_off(self):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass("/tmp/nsh-f4")
|
||||||
|
flow._async_current_entries = lambda: []
|
||||||
|
|
||||||
|
result = await flow.async_step_import(
|
||||||
|
{"name": "Proto", "protocol_host": "shield.local"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert flow._user_data["adb_enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
class TestZeroconfDiscovery:
|
class TestZeroconfDiscovery:
|
||||||
"""P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""
|
"""P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""
|
||||||
|
|
||||||
@@ -640,6 +1016,11 @@ class TestZeroconfDiscovery:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def test_discovery_prefills_the_user_step(self, tmp_path):
|
async def test_discovery_prefills_the_user_step(self, tmp_path):
|
||||||
|
"""t33 F-A: the discovered host is the DEFAULT OF THE SCHEMA KEYS.
|
||||||
|
|
||||||
|
The old check (the attribute was stored) stayed green while the
|
||||||
|
rendered form was blank — this one reads the schema itself.
|
||||||
|
"""
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
@@ -648,9 +1029,13 @@ class TestZeroconfDiscovery:
|
|||||||
|
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
assert result["step_id"] == "user"
|
assert result["step_id"] == "user"
|
||||||
# pre-fill landed: the discovered host is recorded for the schema
|
|
||||||
assert flow._discovered_host == "192.168.1.50"
|
assert flow._discovered_host == "192.168.1.50"
|
||||||
assert result["data_schema"] is not None
|
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
||||||
|
assert defaults[CONF_ADB_HOST] == "192.168.1.50"
|
||||||
|
assert defaults[CONF_PROTOCOL_HOST] == "192.168.1.50"
|
||||||
|
# ...and a discovery-driven form says the address is already known
|
||||||
|
note = result["description_placeholders"]["discovery_note"]
|
||||||
|
assert "Discovered Shield at 192.168.1.50" in note
|
||||||
|
|
||||||
async def test_discovery_without_address_aborts(self, tmp_path):
|
async def test_discovery_without_address_aborts(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
@@ -685,7 +1070,7 @@ class TestZeroconfDiscovery:
|
|||||||
# the hosts moved to the discovery address
|
# the hosts moved to the discovery address
|
||||||
assert entry.options[CONF_PROTOCOL_HOST] == "192.168.1.50"
|
assert entry.options[CONF_PROTOCOL_HOST] == "192.168.1.50"
|
||||||
assert entry.options["adb_host"] == "192.168.1.50"
|
assert entry.options["adb_host"] == "192.168.1.50"
|
||||||
assert hass.config_entries.reloaded_entry_ids == ["e1"]
|
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||||
|
|
||||||
async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path):
|
async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
@@ -738,7 +1123,7 @@ class TestZeroconfDiscovery:
|
|||||||
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
||||||
assert update["adb_host"] == "192.168.1.50"
|
assert update["adb_host"] == "192.168.1.50"
|
||||||
assert update["protocol_host"] == "192.168.1.50"
|
assert update["protocol_host"] == "192.168.1.50"
|
||||||
assert hass.config_entries.reloaded_entry_ids == ["e1"]
|
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||||
|
|
||||||
async def test_deliberately_different_adb_host_is_never_clobbered(self, tmp_path):
|
async def test_deliberately_different_adb_host_is_never_clobbered(self, tmp_path):
|
||||||
"""F2: an explicit ADB override survives a protocol-host IP change."""
|
"""F2: an explicit ADB override survives a protocol-host IP change."""
|
||||||
@@ -767,7 +1152,7 @@ class TestZeroconfDiscovery:
|
|||||||
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
||||||
assert update["protocol_host"] == "192.168.1.50" # protocol followed
|
assert update["protocol_host"] == "192.168.1.50" # protocol followed
|
||||||
assert update["adb_host"] == "10.0.0.9" # override untouched
|
assert update["adb_host"] == "10.0.0.9" # override untouched
|
||||||
assert hass.config_entries.reloaded_entry_ids == ["e1"]
|
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||||
|
|
||||||
|
|
||||||
class TestAppsMapOptionsEditor:
|
class TestAppsMapOptionsEditor:
|
||||||
@@ -776,7 +1161,8 @@ class TestAppsMapOptionsEditor:
|
|||||||
def _options_flow(self, tmp_path, options):
|
def _options_flow(self, tmp_path, options):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
entry = SimpleNamespace(options=dict(options), title="Shield")
|
entry = SimpleNamespace(options=dict(options), title="Shield")
|
||||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
flow = module.NvidiaShieldOptionsFlow()
|
||||||
|
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
return flow
|
return flow
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""A voluptuous mirror faithful enough to SEE a dropped schema default.
|
||||||
|
|
||||||
|
The test environment never installs voluptuous (the config-flow tests import
|
||||||
|
``config_flow`` against import-only stubs), so the harness has to model the two
|
||||||
|
behaviours the flow schemas depend on:
|
||||||
|
|
||||||
|
* ``Optional(field, default=value)`` / ``Required(field, default=value)`` carry
|
||||||
|
that default on the KEY object;
|
||||||
|
* keys compare by FIELD NAME only — real ``voluptuous.Marker.__eq__`` is
|
||||||
|
``self.schema == other`` (no class check at all), so
|
||||||
|
``Optional("x") == Required("x")`` is True, and ``__hash__`` is
|
||||||
|
``hash(self.schema)``. A later re-declaration of the same field therefore
|
||||||
|
collides with the FIRST key object, so a default added by a second
|
||||||
|
declaration is silently dropped.
|
||||||
|
|
||||||
|
That second property is exactly how the t33 F-A discovery-prefill bug hid from
|
||||||
|
the suite: the old stubs reduced every key to its bare string, so a schema
|
||||||
|
whose host key carried no default looked identical to one that carried the
|
||||||
|
discovered address. ``TestVoluptuousMirrorContract`` in
|
||||||
|
``tests/test_config_flow_ux.py`` pins both properties so the harness itself
|
||||||
|
cannot regress into blindness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
UNDEFINED = object()
|
||||||
|
"""Sentinel for "this key declares no default" (real ``voluptuous.UNDEFINED``)."""
|
||||||
|
|
||||||
|
|
||||||
|
class Marker:
|
||||||
|
"""Stand-in for ``voluptuous.Marker`` (the ``Required``/``Optional`` base)."""
|
||||||
|
|
||||||
|
def __init__(self, schema, default=UNDEFINED):
|
||||||
|
self.schema = schema
|
||||||
|
self.default = default
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
# real Marker hashes the field name only
|
||||||
|
return hash(self.schema)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
# real Marker.__eq__ is `self.schema == other`: the FIELD is the whole
|
||||||
|
# comparison — no class check, so Optional("x") == Required("x") (and
|
||||||
|
# even Marker("x") == "x") is True. That is what makes a re-declaration
|
||||||
|
# of a field collide with the first key object and drop its default.
|
||||||
|
return self.schema == other
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{type(self).__name__} {self.schema!r} default={self.default!r}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Required(Marker):
|
||||||
|
"""Stand-in for ``voluptuous.Required``."""
|
||||||
|
|
||||||
|
|
||||||
|
class Optional(Marker):
|
||||||
|
"""Stand-in for ``voluptuous.Optional``."""
|
||||||
|
|
||||||
|
|
||||||
|
def install(vol) -> None:
|
||||||
|
"""Wire the mirror onto a stub ``voluptuous`` module object."""
|
||||||
|
vol.UNDEFINED = UNDEFINED
|
||||||
|
vol.Marker = Marker
|
||||||
|
vol.Required = Required
|
||||||
|
vol.Optional = Optional
|
||||||
|
vol.Schema = lambda schema: schema
|
||||||
|
|
||||||
|
|
||||||
|
def defaults_by_key(schema_dict) -> dict:
|
||||||
|
"""``{field: default}`` for a schema declared with mirror keys."""
|
||||||
|
return {key.schema: key.default for key in declared_keys(schema_dict)}
|
||||||
|
|
||||||
|
|
||||||
|
def declared_keys(schema_dict) -> list:
|
||||||
|
"""The schema's marker keys, in declaration order.
|
||||||
|
|
||||||
|
Raises on a non-marker key: a bare string key means the harness (or the
|
||||||
|
code under test) lost the KEY structure this mirror exists to check.
|
||||||
|
Test-harness bookkeeping keys (``__suggested__`` and friends) are skipped.
|
||||||
|
"""
|
||||||
|
keys = []
|
||||||
|
for key in schema_dict:
|
||||||
|
if isinstance(key, Marker):
|
||||||
|
keys.append(key)
|
||||||
|
continue
|
||||||
|
if isinstance(key, str) and key.startswith("__"):
|
||||||
|
continue
|
||||||
|
raise AssertionError(f"schema key {key!r} is not a voluptuous marker")
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def declared_fields(schema_dict) -> list:
|
||||||
|
"""The field names of a schema, in declaration order."""
|
||||||
|
return [key.schema for key in declared_keys(schema_dict)]
|
||||||
Reference in new issue
Block a user