Compare commits
118
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 | ||
|
|
79faa32b68 | ||
|
|
e51545262e | ||
|
|
9b3142874f | ||
|
|
27715f6932 | ||
|
|
6c925c5241 | ||
|
|
3587747c72 | ||
|
|
2e63135745 | ||
|
|
67174b8536 | ||
|
|
b442440ccf | ||
|
|
697e3ba307 | ||
|
|
f863496ce5 | ||
|
|
07cb73fbb7 | ||
|
|
0ecee66953 | ||
|
|
a3b397920e | ||
|
|
3d026ae60e | ||
|
|
4661e52041 | ||
|
|
c57a2e2ff2 | ||
|
|
2e6dfd8981 | ||
|
|
cd0e634a76 | ||
|
|
80e9a691bc | ||
|
|
17648f43c9 | ||
|
|
f03ef1f531 | ||
|
|
b37a14b920 | ||
|
|
c44c2c2170 | ||
|
|
326f02cb59 | ||
|
|
034cb5c83d | ||
|
|
4222027602 | ||
|
|
f4d750ff92 | ||
|
|
61c7ac99a3 | ||
|
|
1dbb695cec | ||
|
|
8b4fea9a5c | ||
|
|
ce90f59f8e | ||
|
|
3b8e97fe5b | ||
|
|
392553ee26 | ||
|
|
f58be9ed05 | ||
|
|
bc457baae7 | ||
|
|
1f0a4b2021 | ||
|
|
aa4ee7ed62 | ||
|
|
bb2f787fcd | ||
|
|
8885ddd5b5 | ||
|
|
f1e49a661b | ||
|
|
34d09e9f70 | ||
|
|
d35b61078d | ||
|
|
a65c893339 | ||
|
|
5f8c3290cc | ||
|
|
8462ad494c | ||
|
|
b451638c65 | ||
|
|
f071d540bd | ||
|
|
728b7ccbf9 | ||
|
|
61aca9d413 | ||
|
|
906126b0da | ||
|
|
d57c4eaa40 | ||
|
|
b5fc0dacf4 | ||
|
|
e37a3be75f | ||
|
|
a4e7a285d4 | ||
|
|
9a1e1300dd | ||
|
|
49cf402bbe | ||
|
|
27cc69b1d3 | ||
|
|
d07820cde8 | ||
|
|
75fff11d04 | ||
|
|
9c409b3805 | ||
|
|
24981d84a8 | ||
|
|
48c30463ec | ||
|
|
0da84a5a6e | ||
|
|
93b766ae97 | ||
|
|
0a7dd887d7 | ||
|
|
63f76f5328 | ||
|
|
ec2db2ae58 | ||
|
|
4779c20407 | ||
|
|
2ade5091c4 | ||
|
|
4269e233b8 | ||
|
|
37164b6f24 | ||
|
|
a72cf57494 | ||
|
|
a1e671ac6a | ||
|
|
b91a4242ba | ||
|
|
f6a37bdcd5 | ||
|
|
db8712db1a | ||
|
|
8f27618e3d | ||
|
|
4702c9534c | ||
|
|
ce82127a40 | ||
|
|
6b4227cb40 | ||
|
|
d3afda0a25 | ||
|
|
359b96ef9c | ||
|
|
a2f1948a88 |
No files matched your search
@@ -0,0 +1,114 @@
|
|||||||
|
# Quality gate: lint + unit tests + the HA API-drift sweep.
|
||||||
|
#
|
||||||
|
# The test suite is stdlib-only (parsers, intents, running-apps) and runs the
|
||||||
|
# vendored ha_adb_common helpers without installing Home Assistant — see
|
||||||
|
# tests/conftest.py. androidtvremote2 is installed so the real-library
|
||||||
|
# volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real
|
||||||
|
# firmware captures live in tests/fixtures
|
||||||
|
# (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
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install test tooling
|
||||||
|
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
|
||||||
|
run: ruff check custom_components tests
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
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
|
||||||
@@ -52,6 +52,90 @@ Changes inherited from upstream still apply in 0.1.x code: polling is timer-base
|
|||||||
the ADB connection lives inside the media player entity, and fallback app state
|
the ADB connection lives inside the media player entity, and fallback app state
|
||||||
arrives in Phase 3.
|
arrives in Phase 3.
|
||||||
|
|
||||||
|
## State ladder and fallback semantics (Phase 3)
|
||||||
|
|
||||||
|
The media player state is decided top-down; each tier serves only when the
|
||||||
|
ones above it are silent. Both transports live in the entry's
|
||||||
|
`ShieldConnectionManager` (`connection.py`) — ADB is the state source, the
|
||||||
|
remote protocol is the fast push/command path:
|
||||||
|
|
||||||
|
1. **Plug off** — the power switch says `off` → `off`.
|
||||||
|
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
|
||||||
|
3. **ADB wake** (exact `mWakefulness` parse) — asleep → `idle` (standby).
|
||||||
|
4. **Intent** — a just-sent command's target state, self-healing against
|
||||||
|
reality within 30 s (retry goes over the transport that served it).
|
||||||
|
5. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
|
||||||
|
6. **ADB media session** — `playing` / `paused` for apps that publish a
|
||||||
|
session (foreground app preferred).
|
||||||
|
7. **Foreground fallback** — an app is open (`current_app` protocol push,
|
||||||
|
then the ADB `mResumedActivity` parse) but publishes no media session →
|
||||||
|
`on`. Its identity comes from the TTL-capped last-known-state cache, the
|
||||||
|
last channel entered, or the zero-cost "audio playback" hint
|
||||||
|
(`media recently active`) — never resurrecting `playing` past the 60 s
|
||||||
|
state TTL.
|
||||||
|
8. **On** — powered, awake, nothing more specific.
|
||||||
|
|
||||||
|
TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes
|
||||||
|
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 6–7 plus
|
||||||
|
the audio hint are what keep it visible.
|
||||||
|
|
||||||
|
**Standalone setup** accepts any one of the three state sources — the Android
|
||||||
|
TV Remote entity, the Nvidia Remote Protocol (PIN pairing), or ADB
|
||||||
|
media-session mode (ADB enabled + host). The `no_state_source` form error
|
||||||
|
fires only when none of the three is present; ADB-only setups are valid.
|
||||||
|
|
||||||
|
### Channel entry options
|
||||||
|
|
||||||
|
The options flow exposes the §7 channel behavior the integration replicates:
|
||||||
|
the inter-digit **delay** (0.05–1.0 s, default 0.1) and the optional
|
||||||
|
**ENTER press** after the digits (off by default — TiviMate's validated
|
||||||
|
behavior). Both are consumed by `play_media` with
|
||||||
|
`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)
|
||||||
|
|
||||||
|
When enabled in the integration's options (`adb_command_enabled`, **default
|
||||||
|
off**), the integration registers the `nvidia_shield.adb_command` service:
|
||||||
|
send a raw ADB shell command to the entry's persistent connection
|
||||||
|
(`{"command": "your-command", "entity_id": "media_player.<yours>"}` — the
|
||||||
|
entity is optional when exactly one entry provides the service). This is the
|
||||||
|
same security posture as the Home Assistant core `androidtv` integration: a
|
||||||
|
documented, opt-in escape hatch for debugging and automation, registered at
|
||||||
|
setup and unregistered when the last participating entity unloads. It
|
||||||
|
rejects (logs a warning, sends nothing) when the ADB connection is down.
|
||||||
|
|
||||||
|
### Migration from mediasession2mqtt + universal-player glue
|
||||||
|
|
||||||
|
The `mediasession2mqtt` bridge sensors and the universal media player YAML
|
||||||
|
templates the fork replaces are retired by the ladder above: the media player
|
||||||
|
entity already carries the DAG attributes (`media_dag_running_apps` with
|
||||||
|
foreground/last-known enrichment, `media_dag_now_playing` with its source
|
||||||
|
tag) that media-zone orchestration consumed from those sensors. See the
|
||||||
|
appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) for the
|
||||||
|
retirement mapping and the universal-player glue it replaces.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
- **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`).
|
- **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`).
|
||||||
@@ -98,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
|
||||||
@@ -110,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
|
||||||
|
|||||||
@@ -1,16 +1,150 @@
|
|||||||
"""The Nvidia Shield integration."""
|
"""The Nvidia Shield integration.
|
||||||
|
|
||||||
|
Phase 1 architecture (docs/MVP_PLAN.md §2): the entry owns a single
|
||||||
|
ShieldConnectionManager stored in ``entry.runtime_data`` — the persistent ADB
|
||||||
|
socket and all ADB-derived state live there, not on the entities. Entities are
|
||||||
|
thin callback-views over the manager's StateStore.
|
||||||
|
|
||||||
|
ADB is optional: a failed/absent ADB connection never blocks setup (no
|
||||||
|
ConfigEntryNotReady) — the manager retries in the background, or stays
|
||||||
|
passive until the user pairs via the Pair ADB button.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .adb_session import AdbMediaSessionPoller
|
||||||
|
from .connection import ShieldConnectionManager
|
||||||
|
from .const import (
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_PROTOCOL_ENABLED,
|
||||||
|
CONF_PROTOCOL_HOST,
|
||||||
|
DEFAULT_ADB_PORT,
|
||||||
|
DEFAULT_NAME,
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
|
)
|
||||||
|
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, 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):
|
||||||
|
"""Poller factory when ADB is configured AND usable today, else None.
|
||||||
|
|
||||||
|
Returning None leaves the manager passive: the entry still sets up, the
|
||||||
|
entities simply see no ADB state. The Pair ADB button / reconfigure
|
||||||
|
triggers a reload that re-evaluates this.
|
||||||
|
"""
|
||||||
|
if not entry.options.get(CONF_ADB_ENABLED, False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
adb = resolve_adb_config(hass, entry)
|
||||||
|
if adb is None:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"ADB enabled but no host resolvable for entry %s — continuing "
|
||||||
|
"without ADB state.",
|
||||||
|
entry.title,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Only auto-connect when a key already exists — an ADB handshake against
|
||||||
|
# an unpaired device is pointless noise (pairing happens via the Pair ADB
|
||||||
|
# button / config flow, untouched).
|
||||||
|
key_path_ready = await hass.async_add_executor_job(
|
||||||
|
os.path.exists, adb.key_path
|
||||||
|
)
|
||||||
|
if not key_path_ready:
|
||||||
|
_LOGGER.info(
|
||||||
|
"ADB key not found for entry %s — skipping auto-connect. "
|
||||||
|
"Use the Pair ADB button or reconfigure the integration.",
|
||||||
|
entry.title,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 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:
|
||||||
@@ -18,10 +152,77 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN][entry.entry_id] = entry
|
hass.data[DOMAIN][entry.entry_id] = entry
|
||||||
|
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass,
|
||||||
|
entry,
|
||||||
|
poller_factory=await _build_poller_factory(hass, entry),
|
||||||
|
protocol_settings=await _build_protocol_settings(hass, entry),
|
||||||
|
invalid_auth_callback=lambda: entry.async_start_reauth(hass),
|
||||||
|
)
|
||||||
|
entry.runtime_data = manager
|
||||||
|
await manager.async_start()
|
||||||
|
|
||||||
|
# Close the persistent socket on Home Assistant stop as well as unload.
|
||||||
|
async def _close_on_hass_stop(_event) -> None:
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
entry.async_on_unload(
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close_on_hass_stop)
|
||||||
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
"""Manager protocol_settings when the transport is enabled & resolvable.
|
||||||
|
|
||||||
|
Falls back to the androidtv-remote host discovery (the ADB host fallback)
|
||||||
|
until the user pairs, after which the explicit protocol host persists.
|
||||||
|
"""
|
||||||
|
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
host = entry.options.get(CONF_PROTOCOL_HOST) or discover_adb_host(
|
||||||
|
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||||
|
)
|
||||||
|
if not host:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Protocol enabled for entry %s but no host resolvable — continuing "
|
||||||
|
"without the remote-protocol transport. Pair via the flow.",
|
||||||
|
entry.title,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
certfile, keyfile = await hass.async_add_executor_job(protocol_cert_paths, hass)
|
||||||
|
return {
|
||||||
|
"host": host,
|
||||||
|
"client_name": entry.title or "Home Assistant",
|
||||||
|
"certfile": certfile,
|
||||||
|
"keyfile": keyfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||||
|
"""Migrate v1 entries to v2 (adds the remote-protocol fields).
|
||||||
|
|
||||||
|
ADB options carry over untouched; the ADB-host discovery fallback keeps
|
||||||
|
serving as the protocol host until pairing populates it. Downgrades
|
||||||
|
(version > 2) are rejected so HA offers a rollback.
|
||||||
|
"""
|
||||||
|
if config_entry.version > 2:
|
||||||
|
return False
|
||||||
|
if config_entry.version < 2:
|
||||||
|
options = {**config_entry.options}
|
||||||
|
options.setdefault(CONF_PROTOCOL_ENABLED, False)
|
||||||
|
options.setdefault(CONF_PROTOCOL_HOST, "")
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry, options=options, version=2
|
||||||
|
)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Migrated %s config entry to version 2", config_entry.title
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -30,11 +231,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
|
manager = getattr(entry, "runtime_data", None)
|
||||||
|
if manager is not None:
|
||||||
|
await manager.async_stop()
|
||||||
|
entry.runtime_data = None
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
"""ADB media session reader for NVIDIA Shield.
|
"""ADB session adapter for NVIDIA Shield.
|
||||||
|
|
||||||
Thin wrapper around ha_adb_common.AdbClientBase — adds the Shield-specific
|
Since Phase 1 this module is a thin adapter on top of ha_adb_common:
|
||||||
`poll()` method that calls wake check + media session in sequence.
|
|
||||||
|
- ``ensure_adb_key`` (re-exported here for the config flow / Pair ADB button)
|
||||||
|
does the one-time key-material setup — the pairing flow stays untouched.
|
||||||
|
- ``AdbMediaSessionPoller`` remains the protocol-level ADB client used by the
|
||||||
|
ShieldConnectionManager (connection.py) and one-shot pairing connects. The
|
||||||
|
polling loop itself lives in the connection manager, not here.
|
||||||
|
|
||||||
Re-exports MediaSessionInfo and ensure_adb_key for existing importers.
|
Re-exports MediaSessionInfo and ensure_adb_key for existing importers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .ha_adb_common import AdbClientBase, ensure_adb_key as _ensure_adb_key
|
from .ha_adb_common import (
|
||||||
from .ha_adb_common import MediaSessionInfo # noqa: F401 (re-export)
|
AdbClientBase,
|
||||||
|
MediaSessionInfo, # noqa: F401 (re-export)
|
||||||
|
parse_foreground,
|
||||||
|
)
|
||||||
|
from .ha_adb_common import ensure_adb_key as _ensure_adb_key
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,3 +44,13 @@ class AdbMediaSessionPoller(AdbClientBase):
|
|||||||
prefer its media session over stale sessions from other apps.
|
prefer its media session over stale sessions from other apps.
|
||||||
"""
|
"""
|
||||||
return await self.poll_media_sessions(foreground_package)
|
return await self.poll_media_sessions(foreground_package)
|
||||||
|
|
||||||
|
async def poll_foreground(self) -> str | None:
|
||||||
|
"""Foreground package via `dumpsys activity activities` (RESEARCH R4).
|
||||||
|
|
||||||
|
The grep keeps the dump tiny; the parser handles the R4 line wrap.
|
||||||
|
"""
|
||||||
|
dump = await self.shell("dumpsys activity activities 2>/dev/null | grep -iE 'resumed'")
|
||||||
|
if not dump:
|
||||||
|
return None
|
||||||
|
return parse_foreground(dump)
|
||||||
@@ -5,8 +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 import entity_registry as er
|
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
|
||||||
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
|
||||||
@@ -18,6 +17,7 @@ from .const import (
|
|||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
|
from .helpers import discover_adb_host
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -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:
|
||||||
@@ -52,29 +53,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
|||||||
model="Shield TV",
|
model="Shield TV",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_host(self) -> str | None:
|
|
||||||
"""Resolve ADB host from config or Android TV entity."""
|
|
||||||
host = self._entry.options.get(CONF_ADB_HOST)
|
|
||||||
if host:
|
|
||||||
return host
|
|
||||||
|
|
||||||
atv_entity = self._entry.data.get(CONF_ANDROIDTV_ENTITY)
|
|
||||||
if not atv_entity:
|
|
||||||
return None
|
|
||||||
|
|
||||||
registry = er.async_get(self.hass)
|
|
||||||
entity_entry = registry.async_get(atv_entity)
|
|
||||||
if not entity_entry or not entity_entry.config_entry_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
|
||||||
if config_entry:
|
|
||||||
return config_entry.data.get("host")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
||||||
host = self._resolve_host()
|
host = self._entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
||||||
|
self.hass, self._entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||||
|
)
|
||||||
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||||
|
|
||||||
if not host:
|
if not host:
|
||||||
@@ -85,7 +68,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
|||||||
ensure_adb_key, self.hass.config.config_dir
|
ensure_adb_key, self.hass.config.config_dir
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.info("Attempting ADB pairing with %s:%d — approve the prompt on the Shield", host, port)
|
_LOGGER.info(
|
||||||
|
"Attempting ADB pairing with %s:%d — approve the prompt on the Shield",
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
)
|
||||||
|
|
||||||
poller = AdbMediaSessionPoller(host, port, key_path)
|
poller = AdbMediaSessionPoller(host, port, key_path)
|
||||||
if await poller.connect():
|
if await poller.connect():
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -22,17 +22,128 @@ CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
|
|||||||
CONF_ADB_HOST = "adb_host"
|
CONF_ADB_HOST = "adb_host"
|
||||||
CONF_ADB_PORT = "adb_port"
|
CONF_ADB_PORT = "adb_port"
|
||||||
|
|
||||||
|
# Remote-protocol transport (androidtvremote2) — Phase 2
|
||||||
|
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
||||||
|
CONF_PROTOCOL_HOST = "protocol_host"
|
||||||
|
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_PAIR_PORT = 6467
|
||||||
|
|
||||||
ADB_POLL_INTERVAL = 10 # seconds
|
ADB_POLL_INTERVAL = 10 # seconds
|
||||||
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
|
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
|
||||||
DEFAULT_ADB_PORT = 5555
|
DEFAULT_ADB_PORT = 5555
|
||||||
|
|
||||||
|
# Persistent-connection manager timing (Phase 1, docs/MVP_PLAN.md §2):
|
||||||
|
# watchdog reconnects with exponential backoff + jitter; ADB-derived store
|
||||||
|
# fields expire (B5) instead of clinging to stale values.
|
||||||
|
ADB_RECONNECT_BACKOFF_START = 5 # seconds before the first reconnect retry
|
||||||
|
ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
|
||||||
|
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
|
||||||
|
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
|
||||||
|
|
||||||
DEFAULT_NAME = "Nvidia Shield"
|
DEFAULT_NAME = "Nvidia Shield"
|
||||||
|
|
||||||
# Emotiva DAG attribute we read to get available inputs
|
# Emotiva DAG attribute we read to get available inputs
|
||||||
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
||||||
|
|
||||||
# Known Jellyfin app package IDs
|
# Known Jellyfin app package IDs (used to route Jellyfin-family clients
|
||||||
|
# — including Wholphin, a Jellyfin client — to the rich-metadata path).
|
||||||
JELLYFIN_APP_IDS = [
|
JELLYFIN_APP_IDS = [
|
||||||
"org.jellyfin.androidtv",
|
"org.jellyfin.androidtv",
|
||||||
"org.jellyfin.mobile",
|
"org.jellyfin.mobile",
|
||||||
|
"com.github.damontecres.wholphin",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Remote-protocol key names (RemoteKeyCode enum in remotemessage.proto).
|
||||||
|
# HA service kind → protocol keycode string.
|
||||||
|
PROTOCOL_MEDIA_KEYS = {
|
||||||
|
"media_play": "KEYCODE_MEDIA_PLAY",
|
||||||
|
"media_pause": "KEYCODE_MEDIA_PAUSE",
|
||||||
|
"media_stop": "KEYCODE_MEDIA_STOP",
|
||||||
|
"media_next_track": "KEYCODE_MEDIA_NEXT",
|
||||||
|
"media_previous_track": "KEYCODE_MEDIA_PREVIOUS",
|
||||||
|
}
|
||||||
|
PROTOCOL_VOLUME_KEYS = {
|
||||||
|
"volume_up": "KEYCODE_VOLUME_UP",
|
||||||
|
"volume_down": "KEYCODE_VOLUME_DOWN",
|
||||||
|
"volume_mute": "KEYCODE_VOLUME_MUTE",
|
||||||
|
}
|
||||||
|
PROTOCOL_REMOTE_KEYS = {
|
||||||
|
**PROTOCOL_VOLUME_KEYS,
|
||||||
|
"play": "KEYCODE_MEDIA_PLAY",
|
||||||
|
"pause": "KEYCODE_MEDIA_PAUSE",
|
||||||
|
"stop": "KEYCODE_MEDIA_STOP",
|
||||||
|
"up": "KEYCODE_DPAD_UP",
|
||||||
|
"down": "KEYCODE_DPAD_DOWN",
|
||||||
|
"left": "KEYCODE_DPAD_LEFT",
|
||||||
|
"right": "KEYCODE_DPAD_RIGHT",
|
||||||
|
"enter": "KEYCODE_DPAD_CENTER",
|
||||||
|
"home": "KEYCODE_HOME",
|
||||||
|
"back": "KEYCODE_BACK",
|
||||||
|
"menu": "KEYCODE_MENU",
|
||||||
|
"search": "KEYCODE_SEARCH",
|
||||||
|
"power": "KEYCODE_POWER",
|
||||||
|
"channel_up": "KEYCODE_CHANNEL_UP",
|
||||||
|
"channel_down": "KEYCODE_CHANNEL_DOWN",
|
||||||
|
}
|
||||||
|
|
||||||
|
# §7 channel selection: digits only, ~0.1 s apart, no ENTER by default
|
||||||
|
# (replicates HA core androidtv_remote's verified behavior).
|
||||||
|
# Zeroconf discovery (Phase 4): the Android TV remote service type.
|
||||||
|
ZEROCONF_SERVICE_TYPE = "_androidtvremote2._tcp.local."
|
||||||
|
|
||||||
|
# P4-1 apps name/icon map (options flow editor): package → friendly name,
|
||||||
|
# consumed by app_name after the androidtv-entity and protocol-push tiers.
|
||||||
|
CONF_APP_NAME_MAP = "app_name_map"
|
||||||
|
|
||||||
|
# P5-2: optional icon column (package|icon) — package → icon URL, feeding
|
||||||
|
# entity_picture when no Jellyfin session picture is live.
|
||||||
|
CONF_APP_ICON_MAP = "app_icon_map"
|
||||||
|
|
||||||
|
# P5-1: device manifest label cache TTL (long — labels change with app
|
||||||
|
# updates, not per poll).
|
||||||
|
APP_LABEL_TTL = 24 * 3600
|
||||||
|
|
||||||
|
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
|
||||||
|
# (same security posture as the core androidtv integration).
|
||||||
|
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
||||||
|
SERVICE_ADB_COMMAND = "adb_command"
|
||||||
|
|
||||||
|
# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer
|
||||||
|
# (foreground AppState fast-path). Default OFF; the user flips it on and
|
||||||
|
# reports stability on their device.
|
||||||
|
CONF_LOGCAT_EVENTS = "logcat_events"
|
||||||
|
|
||||||
|
# P5-3: duplicate START for the same package inside this window is not
|
||||||
|
# re-stamped (repeat suppression).
|
||||||
|
LOGCAT_SUPPRESS_WINDOW = 15.0
|
||||||
|
|
||||||
|
# P4-2c BrowseMedia app directory fallback when the options map is empty
|
||||||
|
# (curated; the packages are the plan's reference apps).
|
||||||
|
DEFAULT_APP_DIRECTORY = {
|
||||||
|
"ar.tvplayer.tv": "TiviMate",
|
||||||
|
"com.github.damontecres.wholphin": "Wholphin",
|
||||||
|
"com.netflix.ninja": "Netflix",
|
||||||
|
"com.google.android.youtube.tv": "YouTube",
|
||||||
|
}
|
||||||
|
|
||||||
|
CHANNEL_DIGIT_DELAY = 0.1
|
||||||
|
CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay"
|
||||||
|
CONF_CHANNEL_SEND_ENTER = "channel_send_enter"
|
||||||
|
|
||||||
|
# Verified on-device package names (docs/RESEARCH_SHIELD.md): TiviMate does
|
||||||
|
# not publish a MediaSession on Shield Android 11 — it is the headline
|
||||||
|
# Phase-3 fallback app.
|
||||||
|
TIVIMATE_PACKAGE = "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
# Packages to ignore when parsing foreground/audio-playback identities
|
||||||
|
SYSTEM_MEDIA_PACKAGES = [
|
||||||
|
"com.google.android.apps.mediashell", # Chromecast receiver process
|
||||||
|
"com.android.systemui",
|
||||||
|
"com.android.tv.settings",
|
||||||
|
"com.nvidia.diagtools",
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Entry diagnostics for Nvidia Shield (P4-2b, t21 F1 platform-module move).
|
||||||
|
|
||||||
|
HA discovers ``async_get_config_entry_diagnostics`` through the integration's
|
||||||
|
``diagnostics`` platform module — a placement inside media_player.py never
|
||||||
|
serves in a real install (t20 finding F1).
|
||||||
|
|
||||||
|
The payload: redacted entry config (data + options), the manager's StateStore
|
||||||
|
snapshot via the public ``store_snapshot`` accessor, and both transports'
|
||||||
|
health. Cert paths are kept readable; key material / pairing secrets never
|
||||||
|
exist in stored state, and any key carrying a secret-shaped name is redacted
|
||||||
|
defensively.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict, is_dataclass
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.redact import async_redact_data
|
||||||
|
|
||||||
|
from .connection import ShieldConnectionManager
|
||||||
|
|
||||||
|
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
||||||
|
_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):
|
||||||
|
"""Marker-based defensive pass (kept behind the async_redact_data helper)."""
|
||||||
|
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {
|
||||||
|
key: (
|
||||||
|
_REDACTED
|
||||||
|
if any(m in str(key).lower() for m in _REDACT_KEY_MARKERS)
|
||||||
|
else _redact(value)
|
||||||
|
)
|
||||||
|
for key, value in obj.items()
|
||||||
|
}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_redact(item) for item in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _store_snapshot(manager) -> dict[str, dict]:
|
||||||
|
"""TTL-stamped store snapshot; dataclass values expand to dicts."""
|
||||||
|
snapshot = {}
|
||||||
|
for name, (stamp, value) in manager.store.values_stamped().items():
|
||||||
|
if is_dataclass(value) and not isinstance(value, type):
|
||||||
|
value = asdict(value)
|
||||||
|
snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_config_entry_diagnostics(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry
|
||||||
|
) -> dict:
|
||||||
|
"""Entry diagnostics: redacted config + store snapshot + transport health."""
|
||||||
|
manager = getattr(entry, "runtime_data", None)
|
||||||
|
payload: dict = {
|
||||||
|
"entry": {
|
||||||
|
"data": dict(getattr(entry, "data", {}) or {}),
|
||||||
|
"options": dict(getattr(entry, "options", {}) or {}),
|
||||||
|
},
|
||||||
|
"store": {},
|
||||||
|
"transport": {},
|
||||||
|
}
|
||||||
|
if isinstance(manager, ShieldConnectionManager):
|
||||||
|
payload["store"] = manager.store_snapshot()
|
||||||
|
payload["transport"] = {
|
||||||
|
"adb": {"connected": manager.connected, "awake": manager.awake},
|
||||||
|
"protocol": {
|
||||||
|
"active": manager.protocol_active,
|
||||||
|
"available": manager.is_available,
|
||||||
|
"is_on": manager.is_on,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# 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"]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Shared ADB utilities for Home Assistant custom integrations."""
|
"""Shared ADB utilities for Home Assistant custom integrations."""
|
||||||
|
|
||||||
from .adb_client import AdbClientBase
|
from .adb_client import AdbClientBase, parse_wakefulness
|
||||||
|
from .app_label import parse_application_label
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||||
@@ -9,9 +10,16 @@ from .const import (
|
|||||||
ATTR_MEDIA_DAG_ROLE,
|
ATTR_MEDIA_DAG_ROLE,
|
||||||
ATTR_MEDIA_DAG_RUNNING_APPS,
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||||
)
|
)
|
||||||
|
from .foreground import parse_foreground
|
||||||
from .intent import PlaybackIntent
|
from .intent import PlaybackIntent
|
||||||
from .keys import ensure_adb_key
|
from .keys import ensure_adb_key
|
||||||
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
from .logcat_events import foreground_from_logcat
|
||||||
|
from .media_session import (
|
||||||
|
MediaSessionInfo,
|
||||||
|
parse_all_media_sessions,
|
||||||
|
parse_audio_playback,
|
||||||
|
parse_media_session,
|
||||||
|
)
|
||||||
from .running_apps import running_apps_from_sessions, running_apps_from_tasks
|
from .running_apps import running_apps_from_sessions, running_apps_from_tasks
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -26,6 +34,11 @@ __all__ = [
|
|||||||
"PlaybackIntent",
|
"PlaybackIntent",
|
||||||
"ensure_adb_key",
|
"ensure_adb_key",
|
||||||
"parse_all_media_sessions",
|
"parse_all_media_sessions",
|
||||||
|
"parse_application_label",
|
||||||
|
"parse_audio_playback",
|
||||||
|
"parse_foreground",
|
||||||
|
"foreground_from_logcat",
|
||||||
|
"parse_wakefulness",
|
||||||
"parse_media_session",
|
"parse_media_session",
|
||||||
"running_apps_from_sessions",
|
"running_apps_from_sessions",
|
||||||
"running_apps_from_tasks",
|
"running_apps_from_tasks",
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
Subclasses add device-specific polling (media sessions, wake state, etc.).
|
Subclasses add device-specific polling (media sessions, wake state, etc.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
||||||
|
|
||||||
@@ -30,6 +32,33 @@ DEVICE_KEYCODES = {
|
|||||||
|
|
||||||
ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES}
|
ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES}
|
||||||
|
|
||||||
|
# Recognized non-awake mWakefulness= values (see Android PowerManagerInternal).
|
||||||
|
# Anything else is "unknown" — never silently reported as awake or asleep.
|
||||||
|
_WAKEFULNESS_ASLEEP_VALUES = {"Asleep", "Dozing", "Dozing-suspend"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_wakefulness(text: str | None) -> bool | None:
|
||||||
|
"""Parse `dumpsys power` output for the exact ``mWakefulness=`` value (R5).
|
||||||
|
|
||||||
|
Exact-value parse, no substring matching: only the ``mWakefulness=``
|
||||||
|
assignment counts — ``mWakefulnessChanging=false`` and
|
||||||
|
``mWakefulnessSetting=...`` are ignored. Unknown values (e.g. a future
|
||||||
|
firmware's ``mWakefulness=Dozing-ext``) return None so callers treat it
|
||||||
|
as "unknown", never as awake or asleep.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
for line in text.splitlines():
|
||||||
|
match = re.search(r"(?<![A-Za-z])mWakefulness=([^\s,}]+)", line)
|
||||||
|
if match:
|
||||||
|
value = match.group(1)
|
||||||
|
if value == "Awake":
|
||||||
|
return True
|
||||||
|
if value in _WAKEFULNESS_ASLEEP_VALUES:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class AdbClientBase:
|
class AdbClientBase:
|
||||||
"""Base ADB client with connection management and common commands."""
|
"""Base ADB client with connection management and common commands."""
|
||||||
@@ -43,12 +72,28 @@ class AdbClientBase:
|
|||||||
|
|
||||||
# Media session state
|
# Media session state
|
||||||
self._last_info: MediaSessionInfo | None = None
|
self._last_info: MediaSessionInfo | None = None
|
||||||
|
self._last_dump: str | None = None
|
||||||
self._all_sessions: list[MediaSessionInfo] = []
|
self._all_sessions: list[MediaSessionInfo] = []
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> dict:
|
||||||
|
"""Public connection config: {host, port, key_path} (t27 F-NEW3).
|
||||||
|
|
||||||
|
Replaces the private-attr reach-ins for secondary streams — callers
|
||||||
|
(the dedicated logcat stream) share the signer material without
|
||||||
|
touching the poller's socket or its private attributes.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"host": self._host,
|
||||||
|
"port": self._port,
|
||||||
|
"key_path": self._key_path,
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_info(self) -> MediaSessionInfo | None:
|
def last_info(self) -> MediaSessionInfo | None:
|
||||||
return self._last_info
|
return self._last_info
|
||||||
@@ -60,6 +105,7 @@ class AdbClientBase:
|
|||||||
def clear_info(self) -> None:
|
def clear_info(self) -> None:
|
||||||
"""Clear the cached media session info."""
|
"""Clear the cached media session info."""
|
||||||
self._last_info = None
|
self._last_info = None
|
||||||
|
self._last_dump = None
|
||||||
self._all_sessions = []
|
self._all_sessions = []
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
@@ -90,18 +136,15 @@ class AdbClientBase:
|
|||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
"""Disconnect from the device."""
|
"""Disconnect from the device."""
|
||||||
if self._device:
|
if self._device:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
await self._device.close()
|
await self._device.close()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._device = None
|
self._device = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
|
||||||
async def shell(self, cmd: str) -> str | None:
|
async def shell(self, cmd: str) -> str | None:
|
||||||
"""Run a shell command, reconnecting if needed."""
|
"""Run a shell command, reconnecting if needed."""
|
||||||
if not self._connected or not self._device:
|
if (not self._connected or not self._device) and not await self.connect():
|
||||||
if not await self.connect():
|
return None
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
return await self._device.shell(cmd)
|
return await self._device.shell(cmd)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@@ -135,6 +178,24 @@ class AdbClientBase:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def send_digit_keyevent(self, digit: str) -> bool:
|
||||||
|
"""Send a channel digit via `input keyevent` (KEYCODE_0..9 = 7..16)."""
|
||||||
|
if not digit.isdigit() or len(digit) != 1:
|
||||||
|
_LOGGER.warning("Not a single digit: %s", digit)
|
||||||
|
return False
|
||||||
|
result = await self.shell(f"input keyevent {7 + int(digit)}")
|
||||||
|
if result is not None:
|
||||||
|
_LOGGER.debug("ADB digit keyevent %d (%s)", 7 + int(digit), digit)
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
async def send_enter_keyevent(self) -> bool:
|
||||||
|
"""Send ENTER via `input keyevent` (KEYCODE_ENTER = 66) — the ENTER
|
||||||
|
fallback for the §7 channel sequence."""
|
||||||
|
result = await self.shell("input keyevent 66")
|
||||||
|
if result is not None:
|
||||||
|
_LOGGER.debug("ADB ENTER keyevent (66)")
|
||||||
|
return result is not None
|
||||||
|
|
||||||
async def bring_to_foreground(self, package: str) -> bool:
|
async def bring_to_foreground(self, package: str) -> bool:
|
||||||
"""Bring an app to the foreground using monkey command."""
|
"""Bring an app to the foreground using monkey command."""
|
||||||
result = await self.shell(
|
result = await self.shell(
|
||||||
@@ -145,27 +206,60 @@ class AdbClientBase:
|
|||||||
return result is not None
|
return result is not None
|
||||||
|
|
||||||
async def is_awake(self) -> bool | None:
|
async def is_awake(self) -> bool | None:
|
||||||
"""Check if the device screen is on / awake via ADB."""
|
"""Check if the device screen is on / awake via ADB.
|
||||||
|
|
||||||
|
Exact-value mWakefulness= parse (R5); None means "unknown".
|
||||||
|
"""
|
||||||
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
|
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
|
||||||
if result:
|
return parse_wakefulness(result)
|
||||||
return "Awake" in result
|
|
||||||
return None
|
|
||||||
|
|
||||||
# -- Media session polling --
|
# -- Media session polling --
|
||||||
|
|
||||||
async def poll_media_sessions(
|
async def poll_media_sessions(
|
||||||
self, foreground_package: str | None = None, head_lines: int = 200
|
self, foreground_package: str | None = None, head_lines: int | None = 200
|
||||||
) -> MediaSessionInfo | None:
|
) -> MediaSessionInfo | None:
|
||||||
"""Query media sessions. Stores all sessions and returns the best match."""
|
"""Query media sessions. Stores all sessions and returns the best match.
|
||||||
dump = await self.shell(
|
|
||||||
f"dumpsys media_session 2>/dev/null | head -{head_lines}"
|
Args:
|
||||||
)
|
foreground_package: Prefer the session of the foreground app.
|
||||||
|
head_lines: Truncate the dump to N lines (the historical default
|
||||||
|
of 200). Pass None for the full dump — the ShieldConnection
|
||||||
|
manager does so, since the cut silently drops sessions below
|
||||||
|
it (plan blocker B4).
|
||||||
|
"""
|
||||||
|
command = "dumpsys media_session 2>/dev/null"
|
||||||
|
if head_lines is not None:
|
||||||
|
command += f" | head -{head_lines}"
|
||||||
|
dump = await self.shell(command)
|
||||||
|
self._last_dump = dump if dump is not None else None
|
||||||
if dump is None:
|
if dump is None:
|
||||||
return None
|
return None
|
||||||
self._all_sessions = parse_all_media_sessions(dump)
|
self._all_sessions = parse_all_media_sessions(dump)
|
||||||
self._last_info = parse_media_session(dump, foreground_package)
|
self._last_info = parse_media_session(dump, foreground_package)
|
||||||
return self._last_info
|
return self._last_info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_dump(self) -> str | None:
|
||||||
|
"""Raw output of the last media-session query (or None if it failed).
|
||||||
|
|
||||||
|
Shared consumers (the ShieldConnectionManager) parse the "Audio
|
||||||
|
playback" section from this same dump — no extra round trip.
|
||||||
|
"""
|
||||||
|
return self._last_dump
|
||||||
|
|
||||||
|
# -- Health check --
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Verify the connection is actually usable with a cheap shell round trip.
|
||||||
|
|
||||||
|
Returns True only when the device answers the trivial ``echo ok`` with
|
||||||
|
the expected body — a stronger signal than the ``connected`` flag,
|
||||||
|
which can lag behind reality (the watchdog uses this to fire a
|
||||||
|
reconnect instead of trusting a dead socket).
|
||||||
|
"""
|
||||||
|
result = await self.shell("echo ok")
|
||||||
|
return result is not None and result.strip() == "ok"
|
||||||
|
|
||||||
# -- Running apps (recent visible tasks) --
|
# -- Running apps (recent visible tasks) --
|
||||||
|
|
||||||
async def poll_running_apps(self) -> list[dict]:
|
async def poll_running_apps(self) -> list[dict]:
|
||||||
@@ -182,12 +276,11 @@ class AdbClientBase:
|
|||||||
if not result:
|
if not result:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
import re
|
|
||||||
apps = []
|
apps = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
||||||
# System packages to exclude
|
# System packages to exclude
|
||||||
_EXCLUDED = {
|
excluded = {
|
||||||
"com.android.systemui",
|
"com.android.systemui",
|
||||||
"com.android.tv.settings",
|
"com.android.tv.settings",
|
||||||
"com.nvidia.diagtools",
|
"com.nvidia.diagtools",
|
||||||
@@ -206,7 +299,7 @@ class AdbClientBase:
|
|||||||
match = re.search(r"I=(\S+)/", stripped)
|
match = re.search(r"I=(\S+)/", stripped)
|
||||||
if match:
|
if match:
|
||||||
pkg = match.group(1)
|
pkg = match.group(1)
|
||||||
if pkg not in seen and pkg not in _EXCLUDED and not pkg.startswith("."):
|
if pkg not in seen and pkg not in excluded and not pkg.startswith("."):
|
||||||
seen.add(pkg)
|
seen.add(pkg)
|
||||||
apps.append({"package": pkg})
|
apps.append({"package": pkg})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""App friendly-name resolution from the package manifest (P5-1).
|
||||||
|
|
||||||
|
Parses `pm dump <package>` output for the ``application-label`` line — the
|
||||||
|
default-locale variant; localized suffixed variants
|
||||||
|
(``application-label-<lang>:``) are skipped, and multi-label blocks across
|
||||||
|
locales are tolerated (first default-locale label wins), mirroring the
|
||||||
|
tolerant pattern of the androidtv-adb core helper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_APPLICATION_LABEL_RE = re.compile(
|
||||||
|
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
|
||||||
|
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_application_label(dump: str) -> str | None:
|
||||||
|
"""The package's default-locale application label, or None.
|
||||||
|
|
||||||
|
Returns None on empty/failed dumps too — callers fall through to the
|
||||||
|
next name tier.
|
||||||
|
"""
|
||||||
|
if not dump:
|
||||||
|
return None
|
||||||
|
for match in _APPLICATION_LABEL_RE.finditer(dump):
|
||||||
|
label = (match.group(1) or match.group(2) or "").strip()
|
||||||
|
if label and label not in ("null", ""):
|
||||||
|
return label
|
||||||
|
return None
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Foreground-app parsing from `dumpsys activity activities` (RESEARCH_SHIELD R4).
|
||||||
|
|
||||||
|
Primary pattern: ``mResumedActivity: ActivityRecord{... u0 <pkg>/<activity> ...}``
|
||||||
|
(Shield Android 11). ``topResumedActivity`` (newer firmware) is the fallback,
|
||||||
|
checked only after the primary — per R4 finding 1. ``dumpsys window`` focus is
|
||||||
|
NOT parsed (R4 finding 2: the first ``mCurrentFocus`` can be literally null).
|
||||||
|
|
||||||
|
Multi-line ActivityRecord wraps are handled by joining the block until the
|
||||||
|
record closes or a state line arrives (R4 finding 3: match on the joined
|
||||||
|
block, not per-line). ``ResumedActivity``/``Resumed`` duplicates are tolerated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_RESUMED_MARKERS = (
|
||||||
|
("mResumedActivity", "mResumedActivity:"),
|
||||||
|
("topResumedActivity", "topResumedActivity:"),
|
||||||
|
("ResumedActivity", "ResumedActivity:"),
|
||||||
|
("Resumed", "Resumed:"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# `u0 <pkg>/<activity>` — the userId prefix tolerates u0..uN; the package is
|
||||||
|
# anything up to the first `/` (or closing brace when the activity is bare).
|
||||||
|
_PACKAGE_RE = re.compile(r"u\d+\s+(\S+?)\s*/|u\d+\s+(\S+?)\s*}")
|
||||||
|
|
||||||
|
|
||||||
|
def _marker_kind(stripped: str) -> str | None:
|
||||||
|
for kind, marker in _RESUMED_MARKERS:
|
||||||
|
if stripped.startswith(marker):
|
||||||
|
return kind
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resumed_blocks(dump: str) -> list[tuple[str, str]]:
|
||||||
|
"""(kind, joined-text) blocks for each resumed-activity line, in order."""
|
||||||
|
blocks: list[tuple[str, str]] = []
|
||||||
|
kind: str | None = None
|
||||||
|
buf = ""
|
||||||
|
for raw in dump.split("\n"):
|
||||||
|
stripped = raw.strip()
|
||||||
|
marker = _marker_kind(stripped)
|
||||||
|
if marker is not None:
|
||||||
|
if buf:
|
||||||
|
blocks.append((kind, buf))
|
||||||
|
kind, buf = marker, stripped
|
||||||
|
continue
|
||||||
|
if kind is None:
|
||||||
|
continue
|
||||||
|
# Continuation of a wrapped ActivityRecord: join until the record
|
||||||
|
# closes (brace) or the state line completes the block (R4 finding 3).
|
||||||
|
buf += " " + stripped
|
||||||
|
if "}" in stripped or "state=" in stripped:
|
||||||
|
blocks.append((kind, buf))
|
||||||
|
kind, buf = None, ""
|
||||||
|
if buf:
|
||||||
|
blocks.append((kind, buf))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_package(text: str) -> str | None:
|
||||||
|
match = _PACKAGE_RE.search(text)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
package = match.group(1) or match.group(2)
|
||||||
|
if not package or package in ("null", "Window{"):
|
||||||
|
return None
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def parse_foreground(dump: str) -> str | None:
|
||||||
|
"""Foreground package: mResumedActivity primary, topResumed fallback.
|
||||||
|
|
||||||
|
Returns None when neither pattern matches (e.g. pre-parse wrappers) —
|
||||||
|
callers must not fall back to window-focus parsing (R4 finding 2).
|
||||||
|
"""
|
||||||
|
if not dump:
|
||||||
|
return None
|
||||||
|
blocks = _resumed_blocks(dump)
|
||||||
|
for kind, text in blocks:
|
||||||
|
if kind == "topResumedActivity":
|
||||||
|
continue
|
||||||
|
package = _extract_package(text)
|
||||||
|
if package:
|
||||||
|
return package
|
||||||
|
for kind, text in blocks:
|
||||||
|
if kind != "topResumedActivity":
|
||||||
|
continue
|
||||||
|
package = _extract_package(text)
|
||||||
|
if package:
|
||||||
|
return package
|
||||||
|
return None
|
||||||
@@ -13,20 +13,34 @@ class PlaybackIntent:
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
target_state: The MediaPlayerState we expect after the command.
|
target_state: The MediaPlayerState we expect after the command.
|
||||||
command: The service/command name (for retries and logging).
|
command: The command/service name (for retries and logging).
|
||||||
created_at: Monotonic timestamp when the intent was created.
|
created_at: Monotonic timestamp when the intent was created.
|
||||||
prior_state: The source state before the intent (for detecting external overrides).
|
prior_state: The source state before the intent (for detecting external overrides).
|
||||||
retry_count: Number of retries attempted so far.
|
retry_count: Number of retries attempted so far.
|
||||||
|
via_adb: True when the command was successfully served by the ADB
|
||||||
|
keyevent path — the retry must then go over ADB too, not back to
|
||||||
|
the remote that did not serve it.
|
||||||
|
adb_command: The ADB keycode command name matching the intent
|
||||||
|
(play/pause/stop/next/previous); set together with via_adb.
|
||||||
|
via_protocol: True when the command was served by the remote-protocol
|
||||||
|
key push (Phase 2) — the retry then goes over the protocol key
|
||||||
|
again instead of the other transports.
|
||||||
|
protocol_key: The protocol keycode string served with via_protocol.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
target_state: str # MediaPlayerState value (e.g. "playing", "paused")
|
target_state: str # MediaPlayerState value (e.g. "playing", "paused")
|
||||||
command: str
|
command: str
|
||||||
created_at: float
|
created_at: float
|
||||||
prior_state: str | None = None
|
prior_state: str | None = None
|
||||||
retry_count: int = 0
|
retry_count: int = 0
|
||||||
|
via_adb: bool = False
|
||||||
|
adb_command: str | None = None
|
||||||
|
via_protocol: bool = False
|
||||||
|
protocol_key: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, target_state: str, command: str, prior_state: str | None = None) -> "PlaybackIntent":
|
def create(
|
||||||
|
cls, target_state: str, command: str, prior_state: str | None = None
|
||||||
|
) -> "PlaybackIntent":
|
||||||
return cls(
|
return cls(
|
||||||
target_state=target_state,
|
target_state=target_state,
|
||||||
command=command,
|
command=command,
|
||||||
@@ -45,4 +59,6 @@ class PlaybackIntent:
|
|||||||
"command": self.command,
|
"command": self.command,
|
||||||
"retries": self.retry_count,
|
"retries": self.retry_count,
|
||||||
"age_s": self.age_seconds,
|
"age_s": self.age_seconds,
|
||||||
|
"via_adb": self.via_adb,
|
||||||
|
"adb_command": self.adb_command,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Live events push (P5-3 / C-STREAM): logcat foreground trigger.
|
||||||
|
|
||||||
|
Parses Android logcat's ActivityTaskManager ``START`` records and extracts
|
||||||
|
the ``cmp=package`` part so the connection manager can stamp the foreground
|
||||||
|
AppState instantly (sub-heartbeat latency). Logcat carries lifecycle only —
|
||||||
|
no PlaybackState — so the hybrid split (ADB stays the playback/scene state
|
||||||
|
source) is untouched: the stream never reads playback state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_LOG_RECORD_RE = re.compile(r"^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+")
|
||||||
|
_CMP_RE = re.compile(r"cmp=([\w.]+)(?:/[^\s,}]+)?")
|
||||||
|
_START_HINT_RE = re.compile(r"ActivityTaskManager:.*?START\b")
|
||||||
|
|
||||||
|
|
||||||
|
def start_records(lines: list[str]) -> list[str]:
|
||||||
|
"""Split raw logcat lines into START-record buffers (wrapped lines joined).
|
||||||
|
|
||||||
|
A record opens at a line that matches a log timestamp and mentions
|
||||||
|
ActivityTaskManager + START; subsequent lines that do NOT look like a
|
||||||
|
fresh log record are treated as continuations (the intent JSON can wrap
|
||||||
|
across lines). Anything else closes/open the next record.
|
||||||
|
"""
|
||||||
|
records: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if _LOG_RECORD_RE.match(line) and _START_HINT_RE.search(line):
|
||||||
|
if current:
|
||||||
|
records.append("\n".join(current))
|
||||||
|
current = [line]
|
||||||
|
elif current and not _LOG_RECORD_RE.match(line):
|
||||||
|
current.append(line) # wrapped intent text
|
||||||
|
else:
|
||||||
|
if current:
|
||||||
|
records.append("\n".join(current))
|
||||||
|
current = []
|
||||||
|
if current:
|
||||||
|
records.append("\n".join(current))
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def foreground_from_logcat(lines: list[str]) -> str | None:
|
||||||
|
"""The foreground package from the LAST valid START record, or None.
|
||||||
|
|
||||||
|
Non-START lines are ignored entirely; records whose intent carries no
|
||||||
|
``cmp=`` (e.g. service starts) yield nothing; a explicit ``null`` cmp
|
||||||
|
is skipped too. Multi-user prefixes (``START u0``/``u10``...) are
|
||||||
|
tolerated — the user prefix is never part of the cmp value.
|
||||||
|
"""
|
||||||
|
package: str | None = None
|
||||||
|
for record in start_records(lines):
|
||||||
|
match = _CMP_RE.search(record)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
candidate = match.group(1)
|
||||||
|
if candidate and candidate != "null":
|
||||||
|
package = candidate # last valid START wins
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def foreground_packages(lines: list[str]) -> list[str]:
|
||||||
|
"""Every distinct valid START package in order (test seam)."""
|
||||||
|
seen: list[str] = []
|
||||||
|
for record in start_records(lines):
|
||||||
|
match = _CMP_RE.search(record)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
candidate = match.group(1)
|
||||||
|
if candidate and candidate != "null" and candidate not in seen:
|
||||||
|
seen.append(candidate)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"foreground_from_logcat",
|
||||||
|
"foreground_packages",
|
||||||
|
"start_records",
|
||||||
|
]
|
||||||
@@ -4,12 +4,20 @@ Parses `dumpsys media_session` output from ADB to extract now-playing
|
|||||||
information from any app using Android's MediaSession API.
|
information from any app using Android's MediaSession API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MediaSessionInfo:
|
class MediaSessionInfo:
|
||||||
"""Parsed media session data from Android."""
|
"""Parsed media session data from Android.
|
||||||
|
|
||||||
|
Playback position fields come from the session's PlaybackState line:
|
||||||
|
``position`` is the playback offset in ms as of ``updated`` — which is a
|
||||||
|
**device uptime-ms** timestamp (verified on Shield Android 11, R9), not a
|
||||||
|
wallclock value. Use :meth:`position_at_uptime` with the device's current
|
||||||
|
uptime to extrapolate the live position.
|
||||||
|
"""
|
||||||
|
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
artist: str | None = None
|
artist: str | None = None
|
||||||
@@ -17,6 +25,56 @@ class MediaSessionInfo:
|
|||||||
package: str | None = None
|
package: str | None = None
|
||||||
playing: bool = False
|
playing: bool = False
|
||||||
paused: bool = False
|
paused: bool = False
|
||||||
|
position: float | None = None
|
||||||
|
speed: float | None = None
|
||||||
|
updated: int | None = None
|
||||||
|
|
||||||
|
def position_at_uptime(self, now_uptime_ms: float) -> float | None:
|
||||||
|
"""Extrapolate the live playback position at device uptime ``now_uptime_ms``.
|
||||||
|
|
||||||
|
Mirrors HA core's convention: ``position + (now - updated) * speed``.
|
||||||
|
Returns None when the session carries no position sample.
|
||||||
|
"""
|
||||||
|
if self.position is None or self.updated is None:
|
||||||
|
return None
|
||||||
|
speed = self.speed if self.speed is not None else 1.0
|
||||||
|
return self.position + (now_uptime_ms - self.updated) * speed
|
||||||
|
|
||||||
|
|
||||||
|
_PLAYBACK_STATE_RE = re.compile(r"PlaybackState\s*\{([^}]*)\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_playback_state(content: str) -> dict[str, str]:
|
||||||
|
"""Parse the ``key=value`` pairs inside a PlaybackState block.
|
||||||
|
|
||||||
|
Splitting on ", " (rather than searching for ``position=`` anywhere in
|
||||||
|
the line) keeps the ``buffered position`` field from being mistaken for
|
||||||
|
the real ``position``.
|
||||||
|
"""
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
for part in content.split(", "):
|
||||||
|
key, _, value = part.partition("=")
|
||||||
|
if value:
|
||||||
|
fields[key.strip()] = value.strip()
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_float(value: str | None) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(value: str | None) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||||
@@ -32,6 +90,9 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
current_pkg: str | None = None
|
current_pkg: str | None = None
|
||||||
in_active = False
|
in_active = False
|
||||||
current_state = 0 # 0=unknown, 2=paused, 3=playing
|
current_state = 0 # 0=unknown, 2=paused, 3=playing
|
||||||
|
current_position: float | None = None
|
||||||
|
current_speed: float | None = None
|
||||||
|
current_updated: int | None = None
|
||||||
current_meta: MediaSessionInfo | None = None
|
current_meta: MediaSessionInfo | None = None
|
||||||
|
|
||||||
for line in dump.split("\n"):
|
for line in dump.split("\n"):
|
||||||
@@ -47,16 +108,25 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
current_pkg = pkg[1].strip()
|
current_pkg = pkg[1].strip()
|
||||||
in_active = False
|
in_active = False
|
||||||
current_state = 0
|
current_state = 0
|
||||||
|
current_position = None
|
||||||
|
current_speed = None
|
||||||
|
current_updated = None
|
||||||
current_meta = None
|
current_meta = None
|
||||||
|
|
||||||
if "active=true" in stripped:
|
if "active=true" in stripped:
|
||||||
in_active = True
|
in_active = True
|
||||||
|
|
||||||
if in_active and "state=PlaybackState" in stripped:
|
if in_active and "state=PlaybackState" in stripped:
|
||||||
if "state=3" in stripped:
|
match = _PLAYBACK_STATE_RE.search(stripped)
|
||||||
current_state = 3
|
if match:
|
||||||
elif "state=2" in stripped:
|
fields = _parse_playback_state(match.group(1))
|
||||||
current_state = 2
|
try:
|
||||||
|
current_state = int(fields.get("state", 0))
|
||||||
|
except ValueError:
|
||||||
|
current_state = 0
|
||||||
|
current_position = _parse_float(fields.get("position"))
|
||||||
|
current_speed = _parse_float(fields.get("speed"))
|
||||||
|
current_updated = _parse_int(fields.get("updated"))
|
||||||
|
|
||||||
if in_active and "metadata: size=" in stripped and "description=" in stripped:
|
if in_active and "metadata: size=" in stripped and "description=" in stripped:
|
||||||
desc_start = stripped.find("description=")
|
desc_start = stripped.find("description=")
|
||||||
@@ -69,6 +139,9 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
info.package = current_pkg
|
info.package = current_pkg
|
||||||
info.playing = current_state == 3
|
info.playing = current_state == 3
|
||||||
info.paused = current_state == 2
|
info.paused = current_state == 2
|
||||||
|
info.position = current_position
|
||||||
|
info.speed = current_speed
|
||||||
|
info.updated = current_updated
|
||||||
|
|
||||||
if len(parts) >= 1 and parts[0] not in ("null", ""):
|
if len(parts) >= 1 and parts[0] not in ("null", ""):
|
||||||
info.title = parts[0]
|
info.title = parts[0]
|
||||||
@@ -97,6 +170,34 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
return sessions
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
|
def parse_audio_playback(dump: str) -> list[str]:
|
||||||
|
"""Parse the "Audio playback (lastly played comes first)" section.
|
||||||
|
|
||||||
|
Returns the package names that recently played audio, in the dump's own
|
||||||
|
order (most recently played first). This is a zero-cost hint from the same
|
||||||
|
``dumpsys media_session`` call: an app shows up here even when it publishes
|
||||||
|
no MediaSession (e.g. TiviMate, R2). Callers may filter system processes
|
||||||
|
such as ``com.google.android.apps.mediashell`` (the Chromecast receiver).
|
||||||
|
"""
|
||||||
|
if not dump:
|
||||||
|
return []
|
||||||
|
|
||||||
|
packages: list[str] = []
|
||||||
|
in_audio = False
|
||||||
|
for line in dump.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not in_audio:
|
||||||
|
in_audio = "Audio playback" in stripped
|
||||||
|
continue
|
||||||
|
marker = "packages="
|
||||||
|
if marker not in stripped:
|
||||||
|
continue
|
||||||
|
pkg_part = stripped.split(marker, 1)[1]
|
||||||
|
for pkg in pkg_part.replace(",", " ").split():
|
||||||
|
if pkg and pkg not in packages:
|
||||||
|
packages.append(pkg)
|
||||||
|
return packages
|
||||||
|
|
||||||
def parse_media_session(
|
def parse_media_session(
|
||||||
dump: str, foreground_package: str | None = None
|
dump: str, foreground_package: str | None = None
|
||||||
) -> MediaSessionInfo | None:
|
) -> MediaSessionInfo | None:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ def running_apps_from_tasks(
|
|||||||
recent_tasks: list[dict],
|
recent_tasks: list[dict],
|
||||||
media_sessions: list[MediaSessionInfo] | None = None,
|
media_sessions: list[MediaSessionInfo] | None = None,
|
||||||
foreground_package: str | None = None,
|
foreground_package: str | None = None,
|
||||||
|
last_known: dict[str, MediaSessionInfo] | None = None,
|
||||||
) -> list[dict] | None:
|
) -> list[dict] | None:
|
||||||
"""Build a list of running app dicts from recent tasks + media sessions.
|
"""Build a list of running app dicts from recent tasks + media sessions.
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ def running_apps_from_tasks(
|
|||||||
recent_tasks: List of {"package": str} from ADB recent tasks.
|
recent_tasks: List of {"package": str} from ADB recent tasks.
|
||||||
media_sessions: Optional media sessions for playback state enrichment.
|
media_sessions: Optional media sessions for playback state enrichment.
|
||||||
foreground_package: The current foreground app package.
|
foreground_package: The current foreground app package.
|
||||||
|
last_known: Optional per-package TTL-filtered last-known-state cache
|
||||||
|
(P3-3b) — apps without a live session carry their last known
|
||||||
|
playback state as ``last_known_state``/``last_known_title``.
|
||||||
|
|
||||||
Returns None if there are fewer than 2 apps (no point showing a switcher
|
Returns None if there are fewer than 2 apps (no point showing a switcher
|
||||||
with only one option).
|
with only one option).
|
||||||
@@ -57,6 +61,15 @@ def running_apps_from_tasks(
|
|||||||
app["artist"] = session.artist
|
app["artist"] = session.artist
|
||||||
else:
|
else:
|
||||||
app["state"] = "running"
|
app["state"] = "running"
|
||||||
|
# P3-3b: no live session — surface the TTL-capped last-known state
|
||||||
|
cached = (last_known or {}).get(pkg)
|
||||||
|
if cached is not None:
|
||||||
|
if cached.playing:
|
||||||
|
app["last_known_state"] = "playing"
|
||||||
|
elif cached.paused:
|
||||||
|
app["last_known_state"] = "paused"
|
||||||
|
if cached.title:
|
||||||
|
app["last_known_title"] = cached.title
|
||||||
|
|
||||||
apps.append(app)
|
apps.append(app)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Shared config helpers for the NVIDIA Shield integration."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
DEFAULT_ADB_PORT,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdbConfig:
|
||||||
|
"""Resolved ADB connection settings for a config entry."""
|
||||||
|
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
key_path: str
|
||||||
|
|
||||||
|
|
||||||
|
def adb_key_path(hass: HomeAssistant) -> str:
|
||||||
|
"""Path of the ADB private key for this integration (may not exist yet)."""
|
||||||
|
return os.path.join(hass.config.config_dir, ".storage", DOMAIN, "adbkey")
|
||||||
|
|
||||||
|
|
||||||
|
def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]:
|
||||||
|
"""Local (certfile, keyfile) paths for the remote protocol client.
|
||||||
|
|
||||||
|
Mirrors core androidtv_remote's storage pattern: private material under
|
||||||
|
``.storage/<domain>/``. Shared between config-flow pairing and runtime —
|
||||||
|
an entry aborts duplicate setup by unique_id (= device MAC), so one pair
|
||||||
|
of files suffices per install.
|
||||||
|
"""
|
||||||
|
base = os.path.join(hass.config.config_dir, ".storage", DOMAIN)
|
||||||
|
os.makedirs(base, exist_ok=True)
|
||||||
|
return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
|
||||||
|
|
||||||
|
|
||||||
|
def discover_adb_host(
|
||||||
|
hass: HomeAssistant, androidtv_entity_id: str | None
|
||||||
|
) -> str | None:
|
||||||
|
"""Best-effort host for an AndroidTV entity, from its owning config entry."""
|
||||||
|
if not androidtv_entity_id:
|
||||||
|
return None
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
entity_entry = registry.async_get(androidtv_entity_id)
|
||||||
|
if not entity_entry or not entity_entry.config_entry_id:
|
||||||
|
return None
|
||||||
|
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
||||||
|
if config_entry:
|
||||||
|
return config_entry.data.get("host")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
|
||||||
|
"""Resolve host/port/key_path for the entry's ADB connection, or None.
|
||||||
|
|
||||||
|
Priority: explicit ADB host/port options > host from the AndroidTV
|
||||||
|
entity's own config entry.
|
||||||
|
"""
|
||||||
|
host = entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
||||||
|
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||||
|
)
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
|
||||||
|
return AdbConfig(host=host, port=port, key_path=adb_key_path(hass))
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -1,16 +1,25 @@
|
|||||||
{
|
{
|
||||||
"domain": "nvidia_shield",
|
"domain": "nvidia_shield",
|
||||||
"name": "NVIDIA Shield",
|
"name": "NVIDIA Shield",
|
||||||
"codeowners": [
|
"codeowners": [
|
||||||
"@notexpectedyet"
|
"@notexpectedyet"
|
||||||
],
|
],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield",
|
"documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues",
|
"issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"adb-shell>=0.4.0"
|
"adb-shell>=0.4.0",
|
||||||
],
|
"androidtvremote2>=0.2.0"
|
||||||
"version": "2026.10.08"
|
],
|
||||||
|
"version": "2026.10.08",
|
||||||
|
"zeroconf": [
|
||||||
|
"_androidtvremote2._tcp.local."
|
||||||
|
],
|
||||||
|
"integration_type": "device",
|
||||||
|
"loggers": [
|
||||||
|
"adb_shell",
|
||||||
|
"androidtvremote2"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,190 @@
|
|||||||
|
"""Remote entities for NVIDIA Shield — protocol-backed (Phase 2, t10).
|
||||||
|
|
||||||
|
Port of HA core androidtv_remote/remote.py semantics onto the absorbed
|
||||||
|
remote-protocol transport (androidtvremote2) owned by ShieldConnectionManager:
|
||||||
|
|
||||||
|
- ``send_command`` accepts SHORT/START_LONG/END_LONG direction prefixes
|
||||||
|
(``"START_LONG:<key>"``; no prefix = SHORT) and core-style service keys;
|
||||||
|
the core port's hold_secs+direction validation quirks are not carried over —
|
||||||
|
long presses are expressed as the START_LONG/END_LONG pair instead;
|
||||||
|
- ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the
|
||||||
|
``hold_secs`` parameter;
|
||||||
|
- ``activity`` = the protocol current-app push (package; name resolution is
|
||||||
|
deferred app-map work);
|
||||||
|
- turn on/off send the POWER key and honor the is_on push (no superfluous
|
||||||
|
toggling when the state already matches).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from homeassistant.components.remote import RemoteEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .connection import ShieldConnectionManager
|
||||||
|
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS
|
||||||
|
|
||||||
|
_START_PREFIX = "START_LONG:"
|
||||||
|
_END_PREFIX = "END_LONG:"
|
||||||
|
_SHORT_PREFIX = "SHORT:"
|
||||||
|
_POWER_KEY = "KEYCODE_POWER"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_command(command: str) -> tuple[str, str]:
|
||||||
|
"""Key + direction from a command string (default SHORT)."""
|
||||||
|
lowered = command.strip()
|
||||||
|
if lowered.startswith(_START_PREFIX):
|
||||||
|
return lowered[len(_START_PREFIX):], "START_LONG"
|
||||||
|
if lowered.startswith(_END_PREFIX):
|
||||||
|
return lowered[len(_END_PREFIX):], "END_LONG"
|
||||||
|
if lowered.startswith(_SHORT_PREFIX):
|
||||||
|
return lowered[len(_SHORT_PREFIX):], "SHORT"
|
||||||
|
return lowered, "SHORT"
|
||||||
|
|
||||||
|
|
||||||
|
def _key_code_name(command: str) -> str:
|
||||||
|
"""Command kind → protocol keycode (raw KEYCODE_ names pass through)."""
|
||||||
|
mapped = PROTOCOL_REMOTE_KEYS.get(command.lower())
|
||||||
|
if mapped is not None:
|
||||||
|
return mapped
|
||||||
|
upper = command.upper()
|
||||||
|
if upper.startswith("KEYCODE_"):
|
||||||
|
return upper
|
||||||
|
return upper # unknown names: the library reports/validates them
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Add the protocol-backed remote entity for this entry."""
|
||||||
|
async_add_entities([NvidiaShieldRemoteEntity(hass, entry)])
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||||
|
"""Remote entity over the manager's androidtvremote2 transport."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
|
_attr_should_poll = False
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
self._entry = entry
|
||||||
|
self._manager: ShieldConnectionManager | None = getattr(
|
||||||
|
entry, "runtime_data", None
|
||||||
|
)
|
||||||
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote"
|
||||||
|
|
||||||
|
def _manager_of(self) -> ShieldConnectionManager | None:
|
||||||
|
return getattr(self._entry, "runtime_data", None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||||
|
name=self._entry.title,
|
||||||
|
manufacturer="NVIDIA",
|
||||||
|
model="Shield TV",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Unavailable while the transport is active but unpaired/down.
|
||||||
|
|
||||||
|
A passive manager (no protocol transport) keeps the entity available:
|
||||||
|
nothing to transport is not a broken one.
|
||||||
|
"""
|
||||||
|
manager = self._manager_of()
|
||||||
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
|
return True
|
||||||
|
if not manager.protocol_active:
|
||||||
|
return True
|
||||||
|
return manager.is_available is not False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activity(self) -> str | None:
|
||||||
|
"""Current foreground app reported by the protocol push."""
|
||||||
|
manager = self._manager_of()
|
||||||
|
if isinstance(manager, ShieldConnectionManager):
|
||||||
|
return manager.current_app
|
||||||
|
return None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _refresh_state(self) -> None:
|
||||||
|
"""Manager store changed — re-evaluate state (activity/available)."""
|
||||||
|
self._manager = self._manager_of()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
manager = self._manager_of()
|
||||||
|
if isinstance(manager, ShieldConnectionManager):
|
||||||
|
self._manager_unregistered = manager.register_listener(
|
||||||
|
self._refresh_state
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
|
unregistered = getattr(self, "_manager_unregistered", None)
|
||||||
|
if unregistered is not None:
|
||||||
|
unregistered()
|
||||||
|
self._manager_unregistered = None
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs) -> None:
|
||||||
|
"""Wake the device with the POWER key when it reports off."""
|
||||||
|
manager = self._manager_of()
|
||||||
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
|
return
|
||||||
|
if manager.is_on is not False:
|
||||||
|
# Unknown counts as "maybe on" — no superfluous toggle.
|
||||||
|
return
|
||||||
|
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs) -> None:
|
||||||
|
"""Sleep the device with the POWER key when it reports on."""
|
||||||
|
manager = self._manager_of()
|
||||||
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
|
return
|
||||||
|
if manager.is_on is not True:
|
||||||
|
return
|
||||||
|
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
||||||
|
|
||||||
|
async def async_send_command(
|
||||||
|
self,
|
||||||
|
command: list[str],
|
||||||
|
num_repeats: int = 1,
|
||||||
|
delay_secs: float = 0.0,
|
||||||
|
hold_secs: float = 0.0,
|
||||||
|
) -> None:
|
||||||
|
"""Send protocol keys: repeats × (key, or START_LONG/hold/END_LONG)."""
|
||||||
|
manager = self._manager_of()
|
||||||
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
|
return
|
||||||
|
for _ in range(max(1, num_repeats)):
|
||||||
|
for cmd in command:
|
||||||
|
key_part, direction = _split_command(cmd)
|
||||||
|
key_name = _key_code_name(key_part)
|
||||||
|
if hold_secs > 0 and direction == "SHORT":
|
||||||
|
if await manager.send_protocol_key(key_name, "START_LONG"):
|
||||||
|
await asyncio.sleep(max(0.0, hold_secs))
|
||||||
|
await manager.send_protocol_key(key_name, "END_LONG")
|
||||||
|
else:
|
||||||
|
await manager.send_protocol_key(key_name, direction)
|
||||||
|
if delay_secs > 0:
|
||||||
|
await asyncio.sleep(delay_secs)
|
||||||
|
|
||||||
|
async def async_send_text(self, text: str) -> bool:
|
||||||
|
"""Text entry (P5-2) via the protocol send_text (remote surface).
|
||||||
|
|
||||||
|
Falls back to ADB `input text` only when the protocol transport is
|
||||||
|
down or the send failed (the Shield may swallow IME text while its
|
||||||
|
on-screen keyboard is up, RESEARCH_IDEAS §2).
|
||||||
|
"""
|
||||||
|
manager = self._manager_of()
|
||||||
|
if manager is None or not text:
|
||||||
|
return False
|
||||||
|
if await manager.send_text_ime(text):
|
||||||
|
return True
|
||||||
|
return await manager.send_adb_input_text(text)
|
||||||
@@ -4,12 +4,13 @@ import logging
|
|||||||
|
|
||||||
from homeassistant.components.sensor import SensorEntity
|
from homeassistant.components.sensor import SensorEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import STATE_PLAYING, STATE_PAUSED
|
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||||
from homeassistant.core import HomeAssistant, callback, Event
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
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_track_state_change_event
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
|
|
||||||
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
@@ -38,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
|
||||||
@@ -60,49 +63,82 @@ class NvidiaShieldSensorBase(SensorEntity):
|
|||||||
|
|
||||||
|
|
||||||
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||||
"""Current running app on the Shield."""
|
"""Current running app on the Shield.
|
||||||
|
|
||||||
_attr_name = "Current App"
|
Primary source: the androidtv entity's own state. When that entity is
|
||||||
|
unavailable, falls back to the ShieldConnectionManager's StateStore
|
||||||
|
(most recent visible ADB recent-task 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:
|
||||||
super().__init__(hass, entry)
|
super().__init__(hass, entry)
|
||||||
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
|
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
||||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
||||||
|
|
||||||
|
def _manager(self) -> ShieldConnectionManager | None:
|
||||||
|
manager = getattr(self._entry, "runtime_data", None)
|
||||||
|
return manager if isinstance(manager, ShieldConnectionManager) else None
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
self._unsub_listeners.append(
|
self._unsub_listeners.append(
|
||||||
async_track_state_change_event(
|
async_track_state_change_event(
|
||||||
self.hass, [self._androidtv_entity], self._handle_state_change
|
self.hass, [self._androidtv_entity], self._handle_state_change
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
manager = self._manager()
|
||||||
|
if manager is not None:
|
||||||
|
# Refresh when the connection manager updates its store.
|
||||||
|
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_state_change(self, event: Event) -> None:
|
def _handle_state_change(self, event: Event) -> None:
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_store_update(self) -> None:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> str | None:
|
def native_value(self) -> str | None:
|
||||||
state = self.hass.states.get(self._androidtv_entity)
|
state = self.hass.states.get(self._androidtv_entity)
|
||||||
if not state:
|
if state:
|
||||||
return None
|
return state.attributes.get("app_name") or state.attributes.get("app_id")
|
||||||
return state.attributes.get("app_name") or state.attributes.get("app_id")
|
|
||||||
|
# androidtv entity unavailable — StateStore priority chain (P3-3c):
|
||||||
|
# protocol current_app push → ADB mResumedActivity → ADB recents
|
||||||
|
manager = self._manager()
|
||||||
|
if manager is not None:
|
||||||
|
foreground = manager.foreground_app
|
||||||
|
if foreground:
|
||||||
|
return foreground
|
||||||
|
running = manager.running_apps
|
||||||
|
if running:
|
||||||
|
return running[0].get("package")
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
state = self.hass.states.get(self._androidtv_entity)
|
state = self.hass.states.get(self._androidtv_entity)
|
||||||
if not state:
|
if state:
|
||||||
return {}
|
return {
|
||||||
return {
|
"app_id": state.attributes.get("app_id"),
|
||||||
"app_id": state.attributes.get("app_id"),
|
"app_name": state.attributes.get("app_name"),
|
||||||
"app_name": state.attributes.get("app_name"),
|
}
|
||||||
}
|
manager = self._manager()
|
||||||
|
if manager is not None:
|
||||||
|
running = manager.running_apps
|
||||||
|
if running:
|
||||||
|
return {"app_id": running[0].get("package")}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
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,70 +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": "Android TV Remote",
|
"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": "Add the Nvidia Remote Protocol",
|
||||||
|
"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.",
|
"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). It needs a one-time PIN pairing right after this step.",
|
||||||
|
"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": {
|
||||||
|
"title": "Pair the remote protocol",
|
||||||
|
"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": {
|
||||||
|
"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": {
|
||||||
|
"title": "Re-pair Nvidia 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": "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_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select 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. 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 for example ar.tvplayer.tv=TiviMate.",
|
||||||
|
"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_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.",
|
||||||
|
"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": "App names and icons",
|
||||||
|
"adb_command_enabled": "Enable the adb_command service",
|
||||||
|
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||||
|
"channel_send_enter": "Send ENTER after channel digits",
|
||||||
|
"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 — 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 (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 (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, the tested value).",
|
||||||
|
"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": "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,70 +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": "Android TV Remote",
|
"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": "Add the Nvidia Remote Protocol",
|
||||||
|
"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.",
|
"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). It needs a one-time PIN pairing right after this step.",
|
||||||
|
"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": {
|
||||||
|
"title": "Pair the remote protocol",
|
||||||
|
"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": {
|
||||||
|
"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": {
|
||||||
|
"title": "Re-pair Nvidia 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": "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_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select 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. 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 for example ar.tvplayer.tv=TiviMate.",
|
||||||
|
"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_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.",
|
||||||
|
"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": "App names and icons",
|
||||||
|
"adb_command_enabled": "Enable the adb_command service",
|
||||||
|
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||||
|
"channel_send_enter": "Send ENTER after channel digits",
|
||||||
|
"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 — 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 (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 (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, the tested value).",
|
||||||
|
"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": "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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# HA integration-standards audit — custom_components/nvidia_shield
|
||||||
|
|
||||||
|
Status: **COMPLETE.** Every claim is cited to a developer-docs page fetched during
|
||||||
|
this audit (October 2026; the docs site shows "Last updated" stamps of Jun–Oct 2026
|
||||||
|
on the pages cited). Companion docs: `MVP_PLAN.md`, `RESEARCH_SHIELD.md`,
|
||||||
|
`RESEARCH_IDEAS.md`.
|
||||||
|
|
||||||
|
Sources audited (all fetched live):
|
||||||
|
[Manifest](https://developers.home-assistant.io/docs/creating_integration_manifest/) (updated 2026-10-06) ·
|
||||||
|
[Config flow](https://developers.home-assistant.io/docs/core/integration/config_flow/) ·
|
||||||
|
[Options flow](https://developers.home-assistant.io/docs/core/integration/options_flow/) (updated 2026-09-17) ·
|
||||||
|
[YAML configuration](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/) (updated 2026-09-17) ·
|
||||||
|
[Config entries](https://developers.home-assistant.io/docs/config_entries_index/) (updated 2026-06-19) ·
|
||||||
|
[Entity](https://developers.home-assistant.io/docs/core/entity/) ·
|
||||||
|
[Internationalization](https://developers.home-assistant.io/docs/internationalization/core/) ·
|
||||||
|
[Quality scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/) + [rules](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/) ·
|
||||||
|
[Custom actions](https://developers.home-assistant.io/docs/dev_101_services/) ·
|
||||||
|
[Diagnostics](https://developers.home-assistant.io/docs/core/integration/diagnostics/) ·
|
||||||
|
[Tests file structure](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/) ·
|
||||||
|
[Deprecating (incl. YAML-drop pattern)](https://developers.home-assistant.io/docs/deprecating/) ·
|
||||||
|
[ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md) ·
|
||||||
|
[Probatio blog](https://developers.home-assistant.io/blog/2026/09/30/probatio-validation-engine/) ·
|
||||||
|
[Listener+reload deprecation blog](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/) ·
|
||||||
|
[BrowseMediaSource domain blog](https://developers.home-assistant.io/blog/2026/05/20/browse-media-source-root-class/) ·
|
||||||
|
[Central config-flow abort reasons](https://developers.home-assistant.io/blog/2026/09/28/central-config-flow-abort-reasons/) ·
|
||||||
|
[Always reload after reauth](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/) ·
|
||||||
|
[HACS publish requirements](https://hacs.xyz/docs/publish/integration/)
|
||||||
|
|
||||||
|
Code audited: `manifest.json`, `strings.json`, `translations/en.json`,
|
||||||
|
`__init__.py`, `config_flow.py`, `connection.py`, `media_player.py`,
|
||||||
|
`remote.py`, `sensor.py`, `button.py`, `diagnostics.py`, `helpers.py`, tests/,
|
||||||
|
repo root (`hacs.json`, README).
|
||||||
|
|
||||||
|
Legend: ✅ compliant · ⚠️ partial / should improve · ❌ gap (concrete fix in §3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Compliance checklist
|
||||||
|
|
||||||
|
### 1.1 Manifest ([docs](https://developers.home-assistant.io/docs/creating_integration_manifest/))
|
||||||
|
|
||||||
|
| Requirement | Us | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `domain`, `name` naming rules | ✅ | `nvidia_shield` / "NVIDIA Shield" — local-only product, no suffix, per manifest doc's name rules |
|
||||||
|
| `version` required for custom integrations (AwesomeVersion-compatible) | ✅ | `2026.10.08` (CalVer) — "The version of the integration is required for custom integrations" |
|
||||||
|
| `documentation` URL | ✅ | points to the repo; for core submission it must be home-assistant.io (not applicable yet) |
|
||||||
|
| `issue_tracker` | ✅ | present (HACS also lists it as required: [HACS publish](https://hacs.xyz/docs/publish/integration/)) |
|
||||||
|
| `codeowners` | ✅ | `@notexpectedyet` |
|
||||||
|
| `iot_class` from allowed set | ✅ | `local_push` — valid enum value; correct now that both the protocol push and the persistent ADB manager are the primary paths |
|
||||||
|
| `config_flow: true` + `config_flow.py` exists | ✅ | |
|
||||||
|
| `integration_type` | ⚠️ | **Not set.** Required for core integrations with a config flow; for custom it defaults to `hub` — but we are a single-device-per-entry integration, so it "should generally" be `device`. Explicit is recommended ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)). Gap G1 |
|
||||||
|
| `requirements` | ✅ | `adb-shell>=0.4.0`, `androidtvremote2>=0.2.0`; "Custom integrations should only include requirements that are not required by Core" — both are non-core deps. Range-style (`>=`) is accepted for custom integrations (hassfest pins core integrations to `==`; range bounds are the pragmatic custom-integration choice) |
|
||||||
|
| `dependencies` | ✅ | `[]` — we read Jellyfin state via the *already-configured* jellyfin integration and discover via the user's zeroconf service; neither is required to be a `dependencies` entry (manifest doc: dependencies guarantee the integration loads before setup, not that entries exist). G4 below covers the Jellyfin soft-dependency behavior if we ever import its API |
|
||||||
|
| `zeroconf` discovery key | ✅ | `_androidtvremote2._tcp.local.` — exact-service-type entry, not in the "too generic" list |
|
||||||
|
| `single_config_entry` | n/a | Multiple Shields are legitimate — do NOT set ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
|
||||||
|
| `loggers` | ⚠️ | Not set. Recommended so users can raise `adb_shell`/`androidtvremote2` logging under our domain view; `adb_shell` (we wrap) and `androidtvremote2` are exactly the `getLogger` namespaces of our requirements. Gap G1 |
|
||||||
|
| `quality_scale` | n/a | Only meaningful for core integrations; custom integrations can still self-audit (§1.9) but shouldn't fake the key ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
|
||||||
|
| `import_executor` | ✅ | default (true) is right for us |
|
||||||
|
| `disabled` | n/a | |
|
||||||
|
|
||||||
|
### 1.2 Config flow ([docs](https://developers.home-assistant.io/docs/core/integration/config_flow/))
|
||||||
|
|
||||||
|
| Requirement | Us | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Extends `ConfigFlow, domain=DOMAIN` in `config_flow.py` | ✅ | |
|
||||||
|
| `VERSION` (+ migration for old entries) | ✅ | `VERSION = 2` with `async_migrate_entry` handling v1→v2 and rejecting downgrades (`config_entry.version > 2 → False`) — matches the migration contract ("return False → migration_error") |
|
||||||
|
| Unique IDs: stable, string, not user-changeable; `_abort_if_unique_id_configured` | ⚠️ | Protocol path uses the **MAC from the server certificate** (accepted source, [docs list](https://developers.home-assistant.io/docs/core/integration/config_flow/)). But the fallback unique_id is the **`androidtv_entity` entity_id** — entity IDs are user-renameable, which makes them a weak unique_id source (docs list "Device Name / hostname that can be changed" as unacceptable; a renameable entity reference is the same class of problem). Gap G2 |
|
||||||
|
| Discovery (zeroconf): set unique ID, abort `already_configured`, update host on IP change | ✅ | `async_step_zeroconf` sets MAC unique_id, aborts duplicates, and updates `protocol_host`/blank `adb_host` on IP change — this is the [gold `discovery-update-info`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/discovery-update-info) behavior already implemented |
|
||||||
|
| Discovery steps must never finish without user confirmation | ✅ | first-time discovery chains into the user step |
|
||||||
|
| Discovery without unique ID → `_async_handle_discovery_without_unique_id()` | ❌ | not used on the no-MAC path; gap G2 (combine with the unique-id fallback fix) |
|
||||||
|
| `is_matching` / `async_has_matching_flow` for ambiguous discovery | ✅-ish | MAC unique_id set in the flow aborts concurrent flows for the same device (docs: "the flow will immediately abort if another flow is in progress for this unique ID"); the MAC-less path is the only exposure (G2) |
|
||||||
|
| Errors/aborts via strings keys (`errors["base"]`, `async_abort(reason=...)`) | ✅ | all keys exist in `strings.json` config.error/abort |
|
||||||
|
| Interactive pairing (PIN) via flow steps | ✅ | `protocol_pair` uses `async_show_progress`-free two-step pattern (PIN prompt); acceptable — `async_show_progress`/`async_show_progress_done` are for long-running background work |
|
||||||
|
| Reauth flow | ⚠️ | Steps exist (`async_step_reauth`/`reauth_confirm`), triggered via `entry.async_start_reauth(hass)` from the manager's invalid-auth callback ([config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/): reauth must update the entry and abort, not create). But `_finish_reauth` uses `async_update_entry` + `async_schedule_reload` — see G3 (deprecated combo) and [always-reload-after-reauth blog](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/); prefer `async_update_reload_and_abort(self._get_reauth_entry(), ...)`, and reauth flows need to be linked to a config entry ([2024-11-04 blog](https://developers.home-assistant.io/blog/2024/11/04/reauth-reconfigure-entry-id/) — ours gets it via `self.context["entry_id"]`, the mechanism the blog's helper replaces) |
|
||||||
|
| Reconfigure step | ❌ | No `async_step_reconfigure`. The [config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/) says a reconfigure step lets users change **data** that is "not meant to be optional" without an OptionsFlow, and it is a [gold rule](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow). Our hosts live in options, so we are not broken — but G5 documents what a reconfigure step should own (device name + host moves + source toggles) |
|
||||||
|
| Config-entry mutation only via `async_update_entry` | ✅ | everywhere ([config entries doc](https://developers.home-assistant.io/docs/config_entries_index/)) |
|
||||||
|
| Full test coverage of config_flow.py ([docs](https://developers.home-assistant.io/docs/core/integration/config_flow/): "Integrations with a config flow require full test coverage of all code in config_flow.py") | ⚠️ | tests exist (`test_config_flow_pairing.py`, `test_config_flow_protocol.py`) but "full coverage" (also [bronze rule `config-flow-test-coverage`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow-test-coverage)) needs a coverage run to confirm every step/branch; G6 |
|
||||||
|
|
||||||
|
### 1.3 Options flow ([docs](https://developers.home-assistant.io/docs/core/integration/options_flow/))
|
||||||
|
|
||||||
|
| Requirement | Us | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `async_get_options_flow(config_entry) -> OptionsFlow()` **without constructor args**; entry via `self.config_entry` | ❌ | Ours: `NvidiaShieldOptionsFlow(config_entry)` stores `self._config_entry` — the old pattern. Current docs' handler takes **no arguments** and reads `self.config_entry`. Gap G3 |
|
||||||
|
| Prefill via `self.add_suggested_values_to_schema(SCHEMA, self.config_entry.options)` | ⚠️ | We hand-roll `vol.Optional(key, default=current)` per field — works, but the documented helper is the single schema + suggested-values pattern; also lets us keep ONE static schema instead of conditionally building keys (ours rebuilds the schema dict each render, which also means fields disappear/reappear depending on state — legal but harder to test). G3 |
|
||||||
|
| Update listener: `entry.async_on_unload(entry.add_update_listener(...))` | ✅ | registered in `async_setup_entry` ([options doc](https://developers.home-assistant.io/docs/core/integration/options_flow/)) |
|
||||||
|
| Reload-on-options: `OptionsFlowWithReload` OR update listener — **not both** | ❌ | We use the update listener (full reload on every options change, including pure-UI tweaks like app map); the current docs offer `OptionsFlowWithReload` for exactly this. Combined with G3 (the flow's own `async_schedule_reload` calls in reauth/zeroconf) this is the deprecated [listener+reloading-methods combo (error from 2026.12)](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/). Gap G3 |
|
||||||
|
|
||||||
|
### 1.4 strings.json / translations ([docs](https://developers.home-assistant.io/docs/internationalization/core/))
|
||||||
|
|
||||||
|
| Requirement | Us | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `strings.json` adjacent to integration; `translations/en.json` for custom | ✅ | both present, same content |
|
||||||
|
| `config` (steps/errors/aborts), `options` sections | ✅ | complete for every key we return |
|
||||||
|
| Common-string references (`[%key:common::...%]`) | ⚠️ | e.g. `already_configured`, `reauth_successful` are common abort reasons — now [translated centrally](https://developers.home-assistant.io/blog/2026/09/28/central-config-flow-abort-reasons/) for shared reasons; harmless duplicated, minor cleanup |
|
||||||
|
| `title` key | ⚠️ | optional ("fallback to manifest name"); omitted is fine |
|
||||||
|
| `entity` section with `translation_key` on named entities | ⚠️ | Sensors use hard-coded English names (`_attr_name = "Current App"`, `"Now Playing"`). Entity doc: "Avoid hard coding a natural language name, use a translated name instead"; [gold rule `entity-translations`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-translations). Gap G7 |
|
||||||
|
| `exceptions` section for `HomeAssistantError` translations | ❌ | we raise/log instead of raising translated exceptions in the action handler (see G8; also [raising exceptions](https://developers.home-assistant.io/docs/core/platform/raising_exceptions/) pattern) |
|
||||||
|
| `services` section + `services.yaml` | ❌ | `nvidia_shield.adb_command` is registered dynamically with a schema but has **no `services.yaml` and no `services` strings** — see G8. Note [2025-10-24 blog](https://developers.home-assistant.io/blog/2025/10/24/service-web-api-changes/): service translations were removed from the WebSocket/REST service list — descriptions now live only in `services.yaml`/strings, making them required for frontend display |
|
||||||
|
| `issues` section (repairs) | n/a | no repair issues yet (gold [`repair-issues`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/repair-issues) — G9 suggestion for the ADB-unpaired state) |
|
||||||
|
|
||||||
|
### 1.5 Entity standards ([Entity doc](https://developers.home-assistant.io/docs/core/entity/))
|
||||||
|
|
||||||
|
| Requirement | Us | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `has_entity_name = True` ([mandatory for new integrations](https://developers.home-assistant.io/docs/core/entity/)) | ✅ | all entities |
|
||||||
|
| Main-feature entity: `_attr_name = None` (friendly_name = device name) | ✅ | media_player and remote |
|
||||||
|
| Sub-entities named without device/type prefix | ✅-ish | "Current App", "Now Playing", "Pair ADB" — right shape, but hard-coded (G7) |
|
||||||
|
| `unique_id` on every entity ([bronze `entity-unique-id`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-unique-id)) | ✅ | `{DOMAIN}_{entry.entry_id}_{key}` — stable, not user-configurable |
|
||||||
|
| `device_info` on all entities (automatic device registration) | ✅ | every platform; one device per entry keyed by `entry.entry_id`. ⚠️ model is hard-coded "Shield TV" — the protocol's `device_info` (model/vendor/sw_version) is available and populating `model`/`model_id`/`sw_version` from it is a cheap upgrade ([`model_id` added 2024-07](https://developers.home-assistant.io/blog/2024/07/16/model-id-device-info/)). G10 |
|
||||||
|
| `should_poll` — push entities notify via `async_schedule_update_ha_state` / write | ✅ | `_attr_should_poll = False` everywhere + explicit `async_write_ha_state` on store events ([entity doc, subscribing pattern](https://developers.home-assistant.io/docs/core/entity/)) |
|
||||||
|
| `available` ([silver `entity-unavailable`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-unavailable)) | ✅ | media_player/remote/sensors derive from manager health |
|
||||||
|
| Feature flags via `MediaPlayerEntityFeature` enum (no magic numbers — [deprecation](https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation/)) | ✅ | TURN_ON|TURN_OFF|VOLUME_STEP|VOLUME_MUTE|PLAY_MEDIA|BROWSE_MEDIA + playback features; **no `VOLUME_SET`** — correct per the protocol's capability ([RESEARCH_IDEAS §2 C-VOL](RESEARCH_IDEAS.md)) |
|
||||||
|
| States via current `MediaPlayerState` enum | ✅ | imports the component enum; `STANDBY` not used ([deprecated 2025-07](https://developers.home-assistant.io/blog/2025/07/16/media-player-standby-state-deprecation/)) — our asleep→IDLE mapping is the compliant choice |
|
||||||
|
| `entity_category` ([gold `entity-category`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-category)) | ⚠️ | "Pair ADB" button is a device-configuration action → `EntityCategory.CONFIG` is the documented fit; sensors are primary. G11 |
|
||||||
|
| `entity_registry_enabled_default` for noisy entities ([gold](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-disabled-by-default)) | ⚠️ | Now Playing is chatty (per-track state changes); consider `False` for the least-used mirror sensors only — judgment call, low priority |
|
||||||
|
| `_unrecorded_attributes` for frequently-changing attributes ([entity doc](https://developers.home-assistant.io/docs/core/entity/)) | ⚠️ | `extra_state_attributes` (media_player.py:804) carries session/transport details; media_player already unrecords its domain attributes, but our custom attributes that change with every poll should be listed. G12 |
|
||||||
|
| Icon property discouraged vs icon translations ([entity doc](https://developers.home-assistant.io/docs/core/entity/)) | ⚠️ | static `_attr_icon` is the discouraged-but-legal form; app-art uses `entity_picture` (fine). Icon translations only matter for gold |
|
||||||
|
|
||||||
|
### 1.6 Setup / unload / lifecycle ([config entries doc](https://developers.home-assistant.io/docs/config_entries_index/))
|
||||||
|
|
||||||
|
| Requirement | Us | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `entry.runtime_data` for runtime objects ([bronze `runtime-data`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/runtime-data)) | ✅ | `ShieldConnectionManager` on `entry.runtime_data` |
|
||||||
|
| `async_forward_entry_setups` + platform `async_setup_entry` | ✅ | |
|
||||||
|
| `async_unload_entry` forwards unload + cleans up listeners/connections | ✅ | manager stopped, hass.data popped; entities unsubscribe in `async_will_remove_from_hass` |
|
||||||
|
| `EVENT_HOMEASSISTANT_STOP` closes connections | ✅ | via `entry.async_on_unload(bus.async_listen_once(...))` |
|
||||||
|
| `hass.data[DOMAIN][entry_id]` usage | ⚠️ | we still store the entry there for cross-module lookups; fine, but the manager on `entry.runtime_data` makes most consumers redundant — trim opportunistically |
|
||||||
|
| Test-before-setup ([bronze](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup)) | ⚠️ deliberate | We intentionally never raise `ConfigEntryNotReady` (ADB may be unpaired; manager retries in background). This is a justified deviation — the rule's goal (avoid "unknown error" states) is met by the passive-start design + Pair ADB button + protocol `keep_reconnecting` — but it should be **documented** (e.g. a `quality_scale.yaml`-style note or README paragraph). G13 |
|
||||||
|
|
||||||
|
### 1.7 Actions (services) ([docs](https://developers.home-assistant.io/docs/dev_101_services/))
|
||||||
|
|
||||||
|
| Requirement | Us | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Register actions in `async_setup` — "Service actions should always be registered" so they exist even with no loaded entries | ❌ | `adb_command` is registered lazily from the media_player platform when the first entry opts in, and removed when the last unloads. That violates the documented posture (always registered; the doc's rationale: editable/validatable automations + informative errors). Gap G8 |
|
||||||
|
| `services.yaml` describing fields/target | ❌ | missing entirely (G8) |
|
||||||
|
| Names/descriptions from translations ([i18n doc](https://developers.home-assistant.io/docs/internationalization/core/), `services` key) | ❌ | none (G8) |
|
||||||
|
| Target the right level; **target must not be optional** ("Do not implement a default target when none is specified") | ⚠️ | our schema has `Optional("entity_id")` with implicit all-entries fallback — non-compliant targeting. G8 |
|
||||||
|
| Errors raised as exceptions with translations ([silver `action-exceptions`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/action-exceptions); [exceptions i18n](https://developers.home-assistant.io/docs/internationalization/core/)) | ❌ | logs warnings on failure instead of raising `ServiceValidationError(translation_domain=...)`. G8 |
|
||||||
|
|
||||||
|
### 1.8 Diagnostics ([docs](https://developers.home-assistant.io/docs/core/integration/diagnostics/))
|
||||||
|
|
||||||
|
| Requirement | Us | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `async_get_config_entry_diagnostics` | ✅ | correct module-level function (diagnostics.py — previously misplaced inside media_player.py and fixed per t20/t21 finding) |
|
||||||
|
| Redaction of sensitive data via `async_redact_data` | ⚠️ | we ship a hand-rolled `_redact` with marker matching; the doc points at the `async_redact_data` helper with an explicit TO_REDACT list — the helper is the convention (defensive marker-redaction is a reasonable extra on top, but config fields like host should use the helper list). G14 |
|
||||||
|
| Device diagnostics ([gold `diagnostics`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/diagnostics)) | ⚠️ | config-entry only; device diagnostics is the gold-tier add (single-device integration → mostly redundant, note why if skipped) |
|
||||||
|
|
||||||
|
### 1.9 Tests ([tests file structure](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/))
|
||||||
|
|
||||||
|
| Requirement | Us | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Tests per domain under `tests/`, `conftest.py`, `test_init.py` for setup/reload/unload | ⚠️ | `tests/` exists with focused files; there is no `test_init.py` covering setup/reload/unload/migration of the entry (the structure doc names `test_init.py` for exactly that). G6 |
|
||||||
|
| Config-flow full coverage | ⚠️ | see G6 |
|
||||||
|
| Coverage bar for silver: >95% ([`test-coverage`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-coverage)) | ⚠️ | run pytest --cov to baseline; vendored `ha_adb_common` counts toward it |
|
||||||
|
|
||||||
|
### 1.10 Documentation / quality scale ([quality scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/), [rules](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/))
|
||||||
|
|
||||||
|
**Bronze** (baseline for all new integrations): config-flow ✅ · entity-unique-id ✅ · has-entity-name ✅ · runtime-data ✅ · test-before-configure ✅ (both pairing steps test the connection) · unique-config-entry ✅ (MAC path) · action-setup ❌ (G8) · config-flow-test-coverage ⚠️ (G6) · brands ⚠️ (G15: no `brand/` icon in repo; HACS requires brand assets) · docs rules (G16: README has setup docs; needs explicit removal instructions + action descriptions to satisfy `docs-high-level-description`, `docs-installation-instructions`, `docs-removal-instructions`, `docs-actions`).
|
||||||
|
|
||||||
|
**Silver** adds: config-entry-unloading ✅ · reauthentication-flow ✅ (with G3 fix) · entity-unavailable ✅ · log-when-unavailable ⚠️ (verify we log once per transition, not every retry — the manager's backoff looks right; add a test) · parallel-updates ⚠️ (entities are push; declare/verify `PARALLEL_UPDATES` where the platform defaults matter — G12 note) · action-exceptions ❌ (G8) · test-coverage ⚠️ (G6) · docs-configuration-parameters ⚠️ (README should describe every option; strings.json is the source of truth today) · integration-owner ✅ (codeowner declared).
|
||||||
|
|
||||||
|
**Gold** adds (for reference, not required): discovery ✅ (+update-info ✅) · devices ✅ · diagnostics ✅ · entity-category/entity-translations/icon-translations ❌ (G7/G11) · reconfiguration-flow ❌ (G5) · repair-issues ❌ (G9) · extensive docs ❌ (G16).
|
||||||
|
|
||||||
|
## Part 2 — Optional YAML configuration alongside the config flow: current doctrine and canonical pattern
|
||||||
|
|
||||||
|
### 2.1 The rule
|
||||||
|
|
||||||
|
- [ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md) (binding): *"Integrations that communicate with devices and/or services are only configured via the UI. In rare cases, we can make an exception."*
|
||||||
|
- Current [YAML configuration doc](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/): same rule restated — *"Integrations that communicate with devices and/or services are configured via a config flow. In rare cases, we can make an exception. Existing integrations that should not have a YAML configuration are allowed and encouraged to implement a configuration flow and remove YAML support. **Changes to existing YAML configuration for these same existing integrations will no longer be accepted.**"* Also: *new integrations must not use platform-key YAML* (ADR-0007); any YAML must live under the integration's own domain key.
|
||||||
|
- So: for our device integration, YAML is allowed **only** as an *import source* that ends in a real config entry (`source: import`), never as a parallel runtime configuration path. The `import` step name is [reserved for exactly this](https://developers.home-assistant.io/docs/core/integration/config_flow/).
|
||||||
|
|
||||||
|
### 2.2 The as-documented pattern ([deprecating doc](https://developers.home-assistant.io/docs/deprecating/), "Dropping YAML support for an integration")
|
||||||
|
|
||||||
|
The current docs' canonical wiring (shown there in the context of deprecating YAML, and it is the same seeding mechanism an *optional* import uses):
|
||||||
|
|
||||||
|
1. **`__init__.py`: `async_setup(hass, config)` reads the domain key and seeds the import flow.** The exact documented snippet:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
"""Set up the integration."""
|
||||||
|
if DOMAIN not in config:
|
||||||
|
return True
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
|
||||||
|
)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
(`SOURCE_IMPORT` from `homeassistant.config_entries`; this is the current documented form — the deprecated `discovery` step and platform-key imports are gone.)
|
||||||
|
|
||||||
|
2. **`config_flow.py`: `async_step_import` (step name `import`) validates the YAML dict, reuses the same entry-creation path as `async_step_user` (unique_id, `_abort_if_unique_id_configured`, `_create_entry`).** Resulting entries carry `source: import` and behave exactly like UI entries thereafter — options flow, reload, unload all apply. The [config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/) reserves the step name; [config entries doc](https://developers.home-assistant.io/docs/config_entries_index/) treats imported entries as normal config entries.
|
||||||
|
|
||||||
|
3. **Add a `CONFIG_SCHEMA`** for the domain-key YAML ([YAML configuration doc](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/), CONFIG_SCHEMA section) using probatio/vol schema, and re-use the config-flow validation so YAML and UI stay consistent.
|
||||||
|
|
||||||
|
4. If YAML support is ever *dropped* (not our case), the documented procedure is: import step + the core `deprecated_yaml` repair issue with `HOMEASSISTANT_DOMAIN`, `breaks_in_ha_version`, then leave `cv.config_entry_only_config_schema(DOMAIN)` behind ([deprecating doc](https://developers.home-assistant.io/docs/deprecating/)).
|
||||||
|
|
||||||
|
### 2.3 What cannot be imported (honest limits)
|
||||||
|
|
||||||
|
- **Interactive pairing cannot happen during import.** An import step produces a config entry from static YAML data — it cannot wait for a human to approve an ADB prompt or type a PIN shown on the Shield. Our design already anticipates this: **ADB key approval is intentionally decoupled from setup** (the manager auto-connects only when a key exists; the Pair ADB button + flow step handle approval), so an imported entry can start unpaired and become functional after the user pairs. For the **protocol (PIN) pairing** there is currently no post-setup trigger — an imported entry with `protocol_enabled` cannot complete pairing without re-running the config flow. Options: keep protocol pairing out of the YAML surface (recommended), or add a repair issue/button that starts a reauth-style protocol-pair flow for unpaired entries (G9).
|
||||||
|
- **Discovery-derived values (MAC unique_id) cannot come from YAML.** The import can set a *user-chosen* unique_id (e.g. the entity reference) or none; a MAC-less entry weakens duplicate-protection (see G2) — the import should require the `androidtv_entity` reference or an explicit `unique_id`-grade value.
|
||||||
|
- **`source: import` entries show in the UI** and can be migrated to `source: user` only by deletion + re-add; document that YAML edits require deleting/re-importing (or honoring `async_update_entry` on re-import with `reason: options_updated` style updates — simplest is abort `already_configured` on re-import, matching most core integrations).
|
||||||
|
|
||||||
|
### 2.4 HACS / custom-integration policy view
|
||||||
|
|
||||||
|
- [HACS publish requirements](https://hacs.xyz/docs/publish/integration/) do not mention YAML at all — they require repo structure (one integration under `custom_components/`), the six manifest keys (we have all: domain, documentation, issue_tracker, codeowners, name, version), and **brand assets** (an `icon.png` under `brand/` or the brands repo — we currently lack it, G15). HACS never *forbids* an import path; it is simply orthogonal.
|
||||||
|
- The HA project position ([ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md)) is that contributors may drop YAML to reduce burden "power to our contributors" — i.e. no project-level pressure to provide YAML for device integrations, and the quality scale contains no YAML rule. **Recommendation: treat YAML import as optional sugar; do not build it for the MVP** — the UI flow already covers discovery + pairing, and the import path buys only static-foreignKey convenience at the cost of the G2/G9 caveats.
|
||||||
|
|
||||||
|
## Part 3 — Gap list (concrete, file-anchored)
|
||||||
|
|
||||||
|
Ordered by severity; each item names the file(s) and the change.
|
||||||
|
|
||||||
|
| # | Severity | Gap → Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| G3a | **High (breaks 2026.12)** | Deprecated listener+reloading combo: `config_flow.py` `_finish_reauth` (`async_update_entry` + `async_schedule_reload`) and the zeroconf host-update path (`async_update_entry` + `async_schedule_reload`) both double-reload because `__init__.py` registers `entry.add_update_listener(_async_update_listener)` (full reload). Per the [2026-05-07 blog](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/): in `config_flow.py` replace reauth's pair with `self.async_update_reload_and_abort(self._get_reauth_entry(), data_updates={...})`; for zeroconf use `async_update_entry` only and let the listener reload (or `reload_on_update=False` on `_abort_if_unique_id_configured`). Also see [always-reload-after-reauth](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/) |
|
||||||
|
| G3b | **High (deprecation)** | `config_flow.py` `NvidiaShieldOptionsFlow(config_entry)`: constructor must take no args — `async_get_options_flow(config_entry) -> NvidiaShieldOptionsFlow()` reading `self.config_entry` ([options flow doc](https://developers.home-assistant.io/docs/core/integration/options_flow/)); convert hand-rolled defaults to `add_suggested_values_to_schema`; consider `OptionsFlowWithReload` and dropping the update listener (or keep listener for partial reloads of non-structural options — but then never schedule reloads inside the flow) |
|
||||||
|
| G8 | High (silver) | `media_player.py` `_adb_command_service_ensure/_maybe_remove` + missing files: move registration to `__init__.py` `async_setup` (always registered, with `option-dependent` handler that no-ops/logs when no entry opted in); add `services.yaml` (fields: `command` required, `entity_id` **required** target — [targeting rules](https://developers.home-assistant.io/docs/dev_101_services/): target must not be optional; consider `device_id`/config_entry targeting instead of entity); add `strings.json` `services` section (name/description/fields) + `icons.json` service icon; replace warning-logs with `ServiceValidationError(translation_domain=DOMAIN, translation_key=...)` + `exceptions` section ([action-exceptions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/action-exceptions), [exceptions i18n](https://developers.home-assistant.io/docs/internationalization/core/)) |
|
||||||
|
| G2 | High | Unique-ID source fallback: `config_flow.py` uses `androidtv_entity` (entity_id) as unique_id when no MAC exists — renameable/user-changeable → weak. Fix: on the protocol path always MAC; on ADB-only setups without protocol, use `await self._async_handle_discovery_without_unique_id()`-grade handling for discovery, and for manual setup keep the entity-based ID but document the rename caveat, or derive from the device's MAC via the entity's device registry entry (device registry holds `CONNECTION_NETWORK_MAC`) |
|
||||||
|
| G1 | Medium | `manifest.json`: add `"integration_type": "device"`, `"loggers": ["adb_shell", "androidtvremote2"]` ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
|
||||||
|
| G6 | Medium | Tests: add `tests/test_init.py` (setup/reload/unload/migration) per the [tests structure doc](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/); run coverage against `config_flow.py` to satisfy [config-flow-test-coverage](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow-test-coverage) |
|
||||||
|
| G7 | Medium | Entity naming: sensors `sensor.py` "Current App"/"Now Playing" — replace `_attr_name` with `translation_key` + `entity.sensor.*` names in `strings.json` ([entity naming](https://developers.home-assistant.io/docs/core/entity/), [entity-translations](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-translations)); button "Pair ADB" likewise |
|
||||||
|
| G9 | Medium | Unpaired/needs-pairing UX: raise a repair issue (gold [`repair-issues`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/repair-issues)) with an `issues` strings section when the protocol transport is enabled but unpaired, linking to the reauth/pair flow — this also gives YAML-imported entries a pairing path (see §2.3) |
|
||||||
|
| G10 | Low | `media_player.py` `device_info`: populate `model`/`model_id`/`sw_version` from the protocol `device_info` push (available post-pairing; [model_id blog](https://developers.home-assistant.io/blog/2024/07/16/model-id-device-info/)) |
|
||||||
|
| G14 | Low | `diagnostics.py`: switch `_redact` to `async_redact_data` with an explicit TO_REDACT list for `entry.data/options` ([diagnostics doc](https://developers.home-assistant.io/docs/core/integration/diagnostics/)), keeping the marker-based defense as a second layer |
|
||||||
|
| G11 | Low | `button.py`: `_attr_entity_category = EntityCategory.CONFIG` on Pair ADB ([entity-category rule](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-category)) |
|
||||||
|
| G12 | Low | `media_player.py`: declare `_unrecorded_attributes` (class attribute) for fast-changing custom attributes; verify silver `log-when-unavailable` once-per-transition behavior in `connection.py` with a test ([entity doc](https://developers.home-assistant.io/docs/core/entity/), [log-when-unavailable](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/log-when-unavailable)) |
|
||||||
|
| G16 | Low | Docs for bronze: README needs a removal section + per-action documentation ([docs-removal-instructions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/docs-removal-instructions), [docs-actions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/docs-actions)) |
|
||||||
|
| G15 | Low | Brand assets: add `brand/icon.png` to the repo (HACS requirement, [HACS publish](https://hacs.xyz/docs/publish/integration/)) and add the integration to the [brands repo](https://developers.home-assistant.io/docs/creating_integration_brand/) |
|
||||||
|
| G17 | Info | Schema library: `config_flow.py`/`media_player.py` import `voluptuous` — **no change required** ([Probatio blog](https://developers.home-assistant.io/blog/2026/09/30/probatio-validation-engine/): HA aliases the import; voluptuous is no longer installed but the alias resolves; the ban is core-only). New code may import `probatio` directly; opportunistically migrate |
|
||||||
|
| G18 | Info | BrowseMedia: `media_player.py` `async_browse_media` constructs `BrowseMediaSource` without `domain` — `domain` is now a **required** `str` on `BrowseMediaSource.__init__` ([2026-05-20 blog](https://developers.home-assistant.io/blog/2026/05/20/browse-media-source-root-class/)). Verify the media_player-component helper we import and pass `domain=DOMAIN` (children) / the integration domain (root); our `media_content_type="apps"` root is not a `RootBrowseMediaSource` case |
|
||||||
|
| G5 | Info | Reconfigure step (gold [`reconfiguration-flow`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow)): a future `async_step_reconfigure` should own device name + host overrides + source toggles, using `async_update_reload_and_abort(self._get_reconfigure_entry(), ...)` and `_abort_if_unique_id_mismatch` ([config flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/)) |
|
||||||
|
| G4 | Low | Jellyfin auto-detect silently fails when the Jellyfin integration is not yet configured (`config_flow.py` `_find_jellyfin_entity` finds no entities). We deliberately do not list `jellyfin` in `dependencies` (nothing breaks without it); document the ordering caveat in the README instead ([manifest doc, dependencies semantics](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
|
||||||
|
| G13 | Low | Document the deliberate deviation from [test-before-setup](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup): a README paragraph (or an exempted-rule note) explaining that entries start passively without `ConfigEntryNotReady` because ADB may be unpaired, with the Pair ADB button + protocol `keep_reconnecting` + reauth as the recovery UX |
|
||||||
|
| G5 | Info | Reconfigure step (gold [`reconfiguration-flow`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow)): a future `async_step_reconfigure` should own device name + host overrides + source toggles, using `async_update_reload_and_abort(self._get_reconfigure_entry(), ...)` and `_abort_if_unique_id_mismatch` ([config flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/)) |
|
||||||
|
### Explicitly OK as-is (no action)
|
||||||
|
|
||||||
|
- `iot_class: local_push`, requirement ranges, zeroconf entry, `version` (CalVer), `config_flow`, repo structure for HACS (one integration dir), `hacs.json`, devices per entry, feature-flag enums, `MediaPlayerState` usage, `entry.runtime_data`, unload/stop lifecycle, migration versioning shape, entity availability model, and the deliberate no-`ConfigEntryNotReady` design (document it, G13).
|
||||||
|
- Voluptuous imports remain valid through the probatio alias (G17).
|
||||||
+32
-15
@@ -110,13 +110,17 @@ make logcat a true push stream over `adb_shell` for MVP.**
|
|||||||
|
|
||||||
### Why not pure logcat push (yet)
|
### Why not pure logcat push (yet)
|
||||||
|
|
||||||
`adb_shell`'s `AdbDeviceTcpAsync.shell()` is **request → full-response**; there is no
|
CORRECTION (C-STREAM, spike-verified on our pinned range): `adb_shell` 0.4.4
|
||||||
incremental/streaming read API on the stable surface. Running `exec logcat` blocks
|
**does** ship `streaming_shell` — a generator yielding each line of output — on
|
||||||
forever. A true stream means either hacking adb_shell internals (fragile across its
|
both the sync `AdbDevice` and async `AdbDeviceAsync` (async needs the tiny
|
||||||
releases) or adding a dependency (violates constraints). Also, logcat does not
|
`aiofiles` + `async_timeout` extras, both already HA-ecosystem staples). So a
|
||||||
reliably carry *playback state* changes for third-party apps — it reliably carries
|
true logcat push stream needs neither internals hacks nor a new major
|
||||||
**foreground/app lifecycle** events (verified R8: `ActivityTaskManager: START u0
|
dependency. What keeps the hybrid design honest is *device behavior*, not the
|
||||||
{… cmp=<pkg>/<activity>}` on every app open, including home-button → launcher).
|
library: logcat does not reliably carry *playback state* for third-party apps —
|
||||||
|
it reliably carries **foreground/app lifecycle** events (verified R8:
|
||||||
|
`ActivityTaskManager: START u0 {… cmp=<pkg>/<activity>}` on every app open,
|
||||||
|
including home-button → launcher). Streaming stability over multi-hour
|
||||||
|
connections still needs a device spike before we make it the sole trigger.
|
||||||
|
|
||||||
### The hybrid design (target architecture)
|
### The hybrid design (target architecture)
|
||||||
|
|
||||||
@@ -128,8 +132,10 @@ entry.runtime_data = ShieldConnectionManager
|
|||||||
├─ transport B: ADB (adb_shell) — persistent socket, no re-handshake per command
|
├─ transport B: ADB (adb_shell) — persistent socket, no re-handshake per command
|
||||||
├─ watchdog task: health ping + exponential backoff reconnect (5s→…→300s cap)
|
├─ watchdog task: health ping + exponential backoff reconnect (5s→…→300s cap)
|
||||||
├─ trigger listener: foreground-change events
|
├─ trigger listener: foreground-change events
|
||||||
│ (logcat -d -T1 ActivityTaskManager delta snapshots ~2–5 s cadence;
|
├─ trigger listener: foreground-change events
|
||||||
│ Phase 3 option: internals streaming if adb_shell ever supports it)
|
│ Phase 1: logcat delta snapshots ~2–5 s cadence (safe default)
|
||||||
|
│ Phase 2+: streaming_shell logcat stream on a dedicated connection
|
||||||
|
│ (library support verified on 0.4.4; pending device stability spike)
|
||||||
├─ on trigger → cheap `dumpsys activity activities` (mResumedActivity confirm)
|
├─ on trigger → cheap `dumpsys activity activities` (mResumedActivity confirm)
|
||||||
│ → if changed → `dumpsys media_session` fetch
|
│ → if changed → `dumpsys media_session` fetch
|
||||||
├─ heartbeat: `dumpsys media_session` every ~15–30 s while awake (safety net)
|
├─ heartbeat: `dumpsys media_session` every ~15–30 s while awake (safety net)
|
||||||
@@ -267,10 +273,14 @@ Files:
|
|||||||
- `remote.py` (new platform) — protocol-backed remote entity (port of core:
|
- `remote.py` (new platform) — protocol-backed remote entity (port of core:
|
||||||
SHORT/START_LONG/END_LONG command prefixes, num_repeats/delay/hold, activity =
|
SHORT/START_LONG/END_LONG command prefixes, num_repeats/delay/hold, activity =
|
||||||
current app).
|
current app).
|
||||||
- `media_player.py` — command routing switches to protocol keys; volume gains
|
- `media_player.py` — command routing switches to protocol keys; volume gains a
|
||||||
absolute level (VOLUME_SET) + real mute state; availability and is_on come
|
push-based level readout + real mute state (NOTE: androidtvremote2 has NO
|
||||||
|
absolute volume set — HA core closed volume_set for androidtv_remote as
|
||||||
|
not_planned; only VOLUME_STEP | VOLUME_MUTE are claimed, level comes from
|
||||||
|
volume_info push); availability and is_on come
|
||||||
from protocol callbacks; `_is_power_on` ladder: protocol is_on → ADB wake →
|
from protocol callbacks; `_is_power_on` ladder: protocol is_on → ADB wake →
|
||||||
plug off.
|
plug off.
|
||||||
|
plug off.
|
||||||
- `media_player.py` `async_play_media` — §7 channel port via protocol
|
- `media_player.py` `async_play_media` — §7 channel port via protocol
|
||||||
`send_key_command` digits (0.1 s delay) + task-cancel guard; ADB keyevent
|
`send_key_command` digits (0.1 s delay) + task-cancel guard; ADB keyevent
|
||||||
fallback when the protocol is down.
|
fallback when the protocol is down.
|
||||||
@@ -329,16 +339,23 @@ tests cover all ladder branches.
|
|||||||
|
|
||||||
Files:
|
Files:
|
||||||
- Deep links — now cheap: `media_type: app/url` → protocol
|
- Deep links — now cheap: `media_type: app/url` → protocol
|
||||||
`send_launch_app_command` (port of core pattern) + BrowseMedia app directory;
|
`send_launch_app_command` (port of core pattern; the client auto-prefixes
|
||||||
|
`market://launch?id=` for bare package ids — C-LAUNCH, verified in the client
|
||||||
|
source, so no extra branch for app-id vs URI) + BrowseMedia app directory;
|
||||||
per-app `am start` extras via ADB only for apps the protocol can't launch
|
per-app `am start` extras via ADB only for apps the protocol can't launch
|
||||||
(data-driven map in const).
|
(data-driven map in const; discovery recipe via
|
||||||
|
`adb shell cmd package dump <pkg>` in RESEARCH_IDEAS.md).
|
||||||
|
- Channel keys: add `channel_up`/`channel_down` (proto keycodes 166/167 —
|
||||||
|
trivial, no UI needed) alongside the §7 digit path.
|
||||||
- Options: apps name/icon map editor (core androidtv_remote pattern), digit
|
- Options: apps name/icon map editor (core androidtv_remote pattern), digit
|
||||||
delay, IME toggle, privacy scope; finish config entry migration if needed.
|
delay, IME toggle, privacy scope; finish config entry migration if needed.
|
||||||
- Zeroconf discovery (`_androidtvremote2._tcp`) — auto-fill host, update entry on
|
- Zeroconf discovery (`_androidtvremote2._tcp`) — auto-fill host, update entry on
|
||||||
IP change; `manifest.json` gains the `zeroconf` integration dependency.
|
IP change; `manifest.json` gains the `zeroconf` integration dependency.
|
||||||
- `adb_command` service + diagnostics (port androidtv patterns).
|
- `adb_command` service + diagnostics (port androidtv patterns).
|
||||||
- Optional: logcat streaming via adb_shell internals if verified stable (behind
|
- Optional: streaming_shell logcat stream as the trigger source (library
|
||||||
an option flag); otherwise keep snapshot approach.
|
support verified on adb-shell 0.4.4, spike at goal round 9; pending a
|
||||||
|
multi-hour device stability spike) behind an option flag; default stays
|
||||||
|
delta snapshots.
|
||||||
- Trim: fold Current App sensor if StateStore covers it; drop
|
- Trim: fold Current App sensor if StateStore covers it; drop
|
||||||
`running_apps_from_sessions` legacy; remove transitional external-entity code.
|
`running_apps_from_sessions` legacy; remove transitional external-entity code.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Research ideas for ha-nvidia-shield (features, patterns, TiviMate)
|
||||||
|
|
||||||
|
Status: **COMPLETE.** Companion docs: `MVP_PLAN.md`, `RESEARCH_SHIELD.md`.
|
||||||
|
Method: primary sources first — library/integration source code (androidtvremote2,
|
||||||
|
adb_shell, HA core), HA core GitHub issues/PRs, home-assistant.io integration docs,
|
||||||
|
home-assistant.io action docs, and community threads. Citations are inline markdown
|
||||||
|
links. Note: HA's Action *classes* (`MediaPlayerEntityFeature.*`) are what we set in
|
||||||
|
code; HA's *Actions* (`media_player.play_media` etc.) are what users call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What users actually request (from HA core issues, community threads, library issues)
|
||||||
|
|
||||||
|
| # | Request | Source | Take for our MVP |
|
||||||
|
|---|---|---|---|
|
||||||
|
| U1 | Absolute volume set on Android TV players — `media_player.volume_set` repeatedly requested; HA core androidtv_remote closed it **not_planned** | [core#176505](https://github.com/home-assistant/core/issues/176505) | High demand, but the protocol v2 proto has **no usable volume-set primitive shipped** (it reports volume; see C-VOL). Our Phase 2 now ships `VOLUME_STEP|MUTE` + level readout (captain accepted; plan already fixed). If demand ever recurs, the honest fallback is an ADB `input keyevent KEYCODE_VOLUME_*` step loop behind a helper script — **not** a fake supported feature |
|
||||||
|
| U2 | Launching *any* installed app (not just deep-linkable ones) and retrieving the installed-app list | [androidtvremote2#17](https://github.com/tronikos/androidtvremote2/issues/17) | The official NVIDIA Shield app *does* retrieve the app list over another service; androidtvremote2 accepted `market://launch?id=<pkg>` as the app-launch workaround (now built into `send_launch_app_command`, see C-LAUNCH). App-list *retrieval* is not implementable through this client's protocol surface — a data-driven, options-defined app map is the right MVP substitute (already in plan) |
|
||||||
|
| U3 | Text/IME entry (typing into search boxes); behavior **depends on the app and on whether the on-screen keyboard is open** (one-at-a-time chars get replaced in Spotify; whole-string send works) | [androidtvremote2#32](https://github.com/tronikos/androidtvremote2/issues/32) | Library supports `send_text` but behavior is app-dependent. Keep the IME option **default off** (as planned) and document the qualifier rather than trying to be clever |
|
||||||
|
| U4 | Voice search / voice commands | [androidtvremote2#28](https://github.com/tronikos/androidtvremote2/issues/28) — closed; the library now ships `start_voice()`/`VoiceStream`, gated by `enable_voice` and device feature support | Implemented in the lib (PCM 16-bit mono 8 kHz streamed as `RemoteVoiceBegin/Payload/End`). Post-MVP only; note exists so Phase 4 does not re-research |
|
||||||
|
| U5 | Deep-link launching of apps into specific sections (community guide table with ~60 known app URLs, plus how to discover one via `adb shell cmd package dump PACKAGE` and manifest string resolvers) | [deep-link guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921/) | Directly validates Phase-4 deep links; `send_launch_app_command` accepts either a deep link or a bare package id (see C-LAUNCH). The guide's discovery recipe (`cmd package dump <pkg>`, grep `<data`, resolve `@string/deeplink_schema`) is worth copying into our docs/contributor workflow |
|
||||||
|
| U6 | BrowseMedia containing installed apps / activity list so users can launch apps from the media panel instead of scripts | [core#117126](https://github.com/home-assistant/core/pull/117126) (androidtv_remote: "Implement Android TV Remote browse media with apps and activity list") | Validates Phase-4 BrowseMedia app directory; can be built from our own running_apps data or an options-defined list (no protocol app-list needed) |
|
||||||
|
| U7 | Channel-by-number play_media + channel up/down keys on streaming players (Roku, Bravia) | [Roku docs](https://www.home-assistant.io/integrations/roku/), [Bravia docs](https://www.home-assistant.io/integrations/braviatv/) | `play_media(media_content_type: channel)` is the canonical way every TV integration does tuning (Roku, Bravia); our §7 digit path matches. Bonus Roku/Bravia idea: expose `channel_up`/`channel_down` as remote commands (protocol `KEYCODE_CHANNEL_UP=166` / `KEYCODE_CHANNEL_DOWN=167` — in the proto, no new work) |
|
||||||
|
| U8 | TiviMate: start it and switch to a specific channel from an HA automation | [community thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344) (2025, active) | **Exactly our §7 play_media(channel) requirement.** The working recipe there: `remote.turn_on` with `activity: ar.tvplayer.tv` → delay ~7 s (app cold-start) → `media_player.play_media` with `media_content_type: channel` / `media_content_id: "1"` — note the wait; our play_media should not promise cold-start-to-channel in one shot (we don't own app launch speed). Follow-up request in the thread shows demand for tuning VOD/series pages too (unresolved anywhere) |
|
||||||
|
| U9 | Shield app-launcher cards for wall-panel remotes (Firemote adds TiviMate/SmartTube shortcuts); Harmony-style activity switching | [HA-Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48), [Shield launcher blueprint](https://community.home-assistant.io/t/shield-app-launcher-for-harmony-remote/858225) | QoL signal for Phase-4 apps editor + `source_list`; also shows app-name→icon mapping is what users iterate on (supports data-driven app map + icon support) |
|
||||||
|
| U10 | Firemote / universal-remote-card users must hand-define every app shortcut because the Shield integration exposes no app directory | [universal-remote-card discussion](https://github.com/Nerwyn/universal-remote-card/discussions/52) | Independent confirmation of U6/U9 demand; [TiviMate + SmartTube shortcut request](https://github.com/PRProd/HA-Firemote/issues/48) |
|
||||||
|
|
||||||
|
Also verified from an independent direction: [MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4)
|
||||||
|
confirms TiviMate "doesn't capture anything" for notification-based playback
|
||||||
|
detection on the Shield — consistent with our R2 no-media-session finding, and a
|
||||||
|
reminder that projects tried and failed to get sessions out of TiviMate
|
||||||
|
(see §5).
|
||||||
|
|
||||||
|
## 2. androidtvremote2 capabilities we are not using (verified from [library source](https://github.com/tronikos/androidtvremote2))
|
||||||
|
|
||||||
|
Source of truth read: [`androidtv_remote.py`](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/androidtv_remote.py),
|
||||||
|
[`remotemessage.proto`](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto),
|
||||||
|
[`TvKeys.txt`](https://github.com/tronikos/androidtvremote2/blob/main/TvKeys.txt).
|
||||||
|
|
||||||
|
| Capability | API | MVP relevance |
|
||||||
|
|---|---|---|
|
||||||
|
| Key codes + length variants | `send_key_command(key, direction)` — `SHORT`/`START_LONG`/`END_LONG`; accepts int or `"KEYCODE_..."/"POWER"` strings; any code from the 300-strong proto enum | Remote entity port (Phase 2) matches core's SHORT/START_LONG/END_LONG design |
|
||||||
|
| **Text / IME** | `send_text(text)`; also `send_key_command("text:<message>")` routes into send_text | Phase-2 IME option (default off). Known quirk: append-vs-replace inconsistency (#32) — document, don't ship cleverness |
|
||||||
|
| App launch | `send_launch_app_command(app_link_or_app_id)` — **auto-prefixes `market://launch?id=`** when the value has no URL scheme (C-LAUNCH) | `play_media(url)`/`play_media(app)` (Phase 4): bare package names work with zero extra code; deep links like `plex://` pass through unchanged |
|
||||||
|
| Push callbacks | `add_is_on_updated_callback`, `add_current_app_updated_callback`, `add_volume_info_updated_callback`, `add_is_available_updated_callback` | Exactly the manager-listener pattern Phase 1/2 needs; `current_app` push is the Phase-3 primary foreground signal |
|
||||||
|
| Volume info (read-only) | `volume_info` → `(level, max, muted)` pushed on change | Real mute state + numeric level readout in Phase 2 — **but no `volume_set`**; see C-VOL |
|
||||||
|
| Reconnect + reauth distinction | `keep_reconnecting(invalid_auth_callback=<fn>)` with internal exponential backoff 0.1 s → 30 s cap; raises `InvalidAuth` when certs are rejected (device hard reset) | Feeds the transport design; **one owner per transport** — don't build a competing watchdog for the protocol socket; `InvalidAuth` → reauth flow, not retry storm |
|
||||||
|
| Device info | `device_info` (model, vendor, package_name, app_version) | Free diagnostics data for Phase 4 (and a nice setup sanity check) |
|
||||||
|
| App-list push | **Not implemented and not in the protocol this client speaks** ([#17](https://github.com/tronikos/androidtvremote2/issues/17)). Verified against the full proto: the only app-ish messages are `RemoteAppInfo` (IME context) and `RemoteAppLinkLaunchRequest` — no app-list leafs | Confirms: options-defined app name/icon map is the honest substitute; do not promise app-list discovery |
|
||||||
|
| IME sensitivity | Constructor `enable_ime=True` — "**Needed for getting current_app**; disable for devices that show 'Use keyboard on mobile device screen'" | If we make IME configurable, document that disabling it can break the `current_app` push (which Phase 3 depends on) |
|
||||||
|
| Voice | `start_voice()` → `VoiceStream` (PCM 16-bit mono 8 kHz, 20 KB chunks), `enable_voice=True` | Post-MVP |
|
||||||
|
| `RemoteSetVolumeLevel` exists in the proto but is [sent by noone](https://github.com/tronikos/androidtvremote2/issues/176505) (see C-VOL) | — | Do not plan on it for VOLUME_SET |
|
||||||
|
|
||||||
|
## 3. Patterns worth borrowing from other HA integrations (docs verified)
|
||||||
|
|
||||||
|
All four integration docs read in full ([Roku](https://www.home-assistant.io/integrations/roku/),
|
||||||
|
[Apple TV](https://www.home-assistant.io/integrations/apple_tv/),
|
||||||
|
[Bravia](https://www.home-assistant.io/integrations/braviatv/),
|
||||||
|
[androidtv](https://www.home-assistant.io/integrations/androidtv/)).
|
||||||
|
|
||||||
|
| Integration | Mechanism | What to borrow | Effort for us |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Roku** | `play_media(media_content_type: channel)` → tune by number (e.g. `5.1`); `select_source` accepts app name *or* app id; BrowseMedia for apps; channel tuning for Roku TV devices; `media_content_type: app` with `extra.content_id`/`extra.media_type` for content deeplinks; `remote` commands incl. `channel_up`/`channel_down`; active app + active app id sensors; `media_content_type: url` + `extra{format,name,thumbnail,artist_name}` for PlayOnRoku direct playback; obtains app list via a published XML endpoint (`/query/apps`) | *Channel tuning semantic* — exactly our `channel` port (validated). *App deep link envelope shape*: `media_content_type: app` with `extra.content_id`/`extra.media_type` is a richer pattern than raw URLs if we later want per-app content launches — record it but don't build it for MVP. *Source list built from apps* matches our `select_source` = launch app. *Channel up/down as remote commands* — bolt `channel_up`/`channel_down` onto Phase-2 remote command set (keycodes 166/167, free) | Low–medium |
|
||||||
|
| **Apple TV** | `play_media(media_content_type: url)` with `youtube://...`, `https://www.netflix.com/title/...` "Deep Links" (canonical how-to + link-discovery tips via iOS share sheets); `select_source` + BrowseMedia "Apps" folder for launching apps; `keyboard` entity (binary_sensor "keyboard focused") + three text actions (`set/append/clear_keyboard_text`) driven by keyboard-focus push; remote entity with `num_repeats`/`delay_secs`/`hold_secs` | *play_media(url) deep-link semantics* for Phase 4, direct port (mediatype `url` → `send_launch_app_command`). *"Apps folder"* validation for BrowseMedia. *Keyboard-focus push → typed-text entry* is Apple TV's answer to #32's append/replace ambiguity — a keyboard entity would need an IME-push equivalent the Shield protocol does not give us cheaply, so for MVP ship `send_text` only when IME is on (nothing fancier). *Remote entity parameters* (`num_repeats`/`delay_secs`/`hold_secs`) are the shape we already planned | Medium (only if we chase keyboard); Low for the rest |
|
||||||
|
| **Sony Bravia** | `play_media` matches channel number, **exact or partial app/channel name**, or URI string; BrowseMedia lists installed apps + TV channels; remote commands incl. `Num0..Num9` (digit-typing built into the remote platform); documented real-world limitation that TVs running Android/Google TV lose in-app playback info unless you pair with Google Cast (they recommend **Universal Media Player with two children** — exactly the problem our all-in-one design solves natively) | *Fuzzy matching on media_content_id* (number OR exact/partial name) is a small and very forgiving UX win for `channel` — accept a numeric string strictly, but also accept exact channel/app names from an options-defined map if cheap. *The Bravia doc explicitly documents the Cast+Universal-Media-Player workaround* — citable pattern that our native ladder replaces for Shield users | Low (for the simple variant) |
|
||||||
|
| **androidtv (core, ADB/Fire TV)** | `select_source` = launch app; **`!` prefix closes app** (`!com.netflix.ninja`); options editors for *apps list, exclude unknown, arbitrary ADB turn_on/turn_off commands, per-app `state_detection_rules`*; screen-capture album art option; `adb_command`/`upload`/`download` actions; `learn_sendevent` helper | *`!` close-app prefix is a tiny, delightful, zero-new-dep feature* — implement for free alongside our Phase-4 launch path; document it as an androidtv compatibility nicety. *Arbitrary turn_on/turn_off commands as options* is worth matching (we already have shell; gives users a knob without code). *`state_detection_rules`* (per-app playing/paused conditions over `media_session_state`, `audio_state`, `wake_lock_size`) is the blueprint for our last-known cache tier to become *configurable* if users need app-specific truth — out of ADB/MVP scope but record it. *Screen-capture album art* is the precedent for the screencap idea in §4 (Roku/Bravia do not need it; we do) | Low |
|
||||||
|
|
||||||
|
Canonical patterns (all four):
|
||||||
|
`select_source` = launch app with `source_list` of apps; BrowseMedia with a flat
|
||||||
|
"Apps" directory; `play_media` channel digits or named matches; per-app
|
||||||
|
icon/name map; documented `media_content_type` vocabulary (`channel`, `app`, `url`,
|
||||||
|
`music`, `video`) — we should mirror this vocabulary exactly in Phase 2/4.
|
||||||
|
|
||||||
|
## 4. adb_shell capabilities we are not using (verified from [adb_shell source](https://github.com/JeffLIrion/adb_shell))
|
||||||
|
|
||||||
|
Source of truth read: [`adb_shell/adb_device.py`](https://github.com/JeffLIrion/adb_shell/blob/master/adb_shell/adb_device.py) (the full file) and [README.rst](https://raw.githubusercontent.com/JeffLIrion/adb_shell/master/README.rst).
|
||||||
|
|
||||||
|
| Capability | API | MVP relevance |
|
||||||
|
|---|---|---|
|
||||||
|
| **Streaming shell** | `AdbDevice.streaming_shell(command, decode=True)` — a *generator that yields lines as they arrive, until the connection is closed* | ⚠️ Plan correction. `MVP_PLAN.md` §2 currently says "`adb_shell`'s shell() is request→full-response; there is no incremental/streaming read API". **That is no longer accurate on master** — `streaming_shell` exists as a documented generator on `AdbDevice`. Two caveats before acting: (a) verify it is present in the resolved version we pin (repo last pushed 2024‑10; check release ≥ ours covers it — the version is old enough that the feature set may differ slightly); (b) verify the *async* device class exposing it (the source read was the sync `AdbDevice`; the async subclass typically mirrors). If both check out, MVP_PLAN's "Phase-4 optional: logcat streaming behind an option" becomes **much cheaper than hacking adb_shell internals** — and the deferred logcat snapshot approach gains a real path. Rank as the single highest-value adb_shell finding |
|
||||||
|
| **FileSync** | `push()`, `pull(device_path, local_path, ...)`, `list(device_path)` — full FileSync protocol | Enables clean `pull`-based `screencap -p /local && pull` work flows (crisper than `shell screencap -p` stdout parsing) for album art, debugging, or future thumbnails; zero new deps |
|
||||||
|
| Lazy shell decode | `decode=False` on shell | Micro-win for binary/inspection commands |
|
||||||
|
| USB | `AdbDeviceUsb` (experimental, `[usb]` extra) | Not for MVP (cradle-cable setup); note for future reference only |
|
||||||
|
| Long-lived TCP connect | `AdbDeviceTcp(host, port, default_transport_timeout_s=...)` + `connect(rsa_keys=[signer], auth_timeout_s=...)` with per-call timeouts | What Phase 1 already relies on; the per-transaction timeout fields (`transport_timeout_s`/`read_timeout_s`/`timeout_s`) are worth being explicit about in the manager's health-check design rather than default-None everywhere |
|
||||||
|
|
||||||
|
## 5. TiviMate specifics
|
||||||
|
|
||||||
|
**What TiviMate gives us: essentially nothing at the device level.**
|
||||||
|
- Package `ar.tvplayer.tv`; no media session (R2, verified twice — before/after a
|
||||||
|
zap produced byte-identical output) and appears only in the "Audio playback"
|
||||||
|
section of `dumpsys media_session`.
|
||||||
|
- [MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4):
|
||||||
|
independent confirmation that TiviMate produces nothing captureable by
|
||||||
|
notification/session bridges on the Shield.
|
||||||
|
- [Channel-switch automation thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344):
|
||||||
|
the community's only working channel-select recipe is the digit-keyevent path we
|
||||||
|
already planned (plus a ~7 s delay for app cold start).
|
||||||
|
- **No deep link published by users** in the deep-linking guide or elsewhere for
|
||||||
|
`ar.tvplayer.tv` — treat as "no shortcut"; if ever one surfaces, `am start` with
|
||||||
|
extras via ADB is the Phase-4 "apps the protocol can't launch" seam.
|
||||||
|
- **Channel list export:** TiviMate does not expose a device-side API for the
|
||||||
|
channel list the community can use — the ecosystem gets channels from the
|
||||||
|
upstream M3U/XMLTV sources that TiviMate loads, not from the app. Community
|
||||||
|
projects around TiviMate (playlists, EPG ([EPGTalk](https://github.com/acidjesuz/EPGTalk)), playlist auto-updaters) take
|
||||||
|
their data from those links. **MVP implication:** if we ever want "channel
|
||||||
|
N = <name>" metadata, the data side belongs to the user's playlist/EPG pipeline
|
||||||
|
(e.g. options field to store a channel list file), *not* to the integration
|
||||||
|
scraping the TV.
|
||||||
|
- **Channel/zap-change detection while watching:** no project has reliable on-device
|
||||||
|
telemetry (the R2 capture shows the dump is zap-invariant). Realistic best
|
||||||
|
signals remain the ones we have chosen: A "Audio playback" presence (identity)
|
||||||
|
and `last_channel` cache (what *we* last asked for). The Phase-1 optional
|
||||||
|
logcat-on-a-device process channel may eventually reveal app-internal logs, but
|
||||||
|
no public, stable, IPTV-app-level pattern exists to borrow.
|
||||||
|
|
||||||
|
**Conclusion:** our current design (digits via keys, foreground fallback + audio-playback
|
||||||
|
hint + last-known cache) is at parity with what the community does; the only
|
||||||
|
additions worth recording are the ~7 s app-cold-start note in automation docs and the
|
||||||
|
"channel list data belongs outside the integration" decision.
|
||||||
|
|
||||||
|
## 6. Ranked ideas
|
||||||
|
|
||||||
|
Ranking by: (a) fits locked MVP decisions, (b) cost, (c) user-verifiable payoff.
|
||||||
|
"Phase" is where it should land in the current plan.
|
||||||
|
|
||||||
|
| Rank | Idea | MVP fit | Effort | Phase |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | **`channel_up` / `channel_down` on the remote+media_player command sets** — free with the protocol's `KEYCODE_CHANNEL_UP`(166)/`KEYCODE_CHANNEL_DOWN`(167); Roku/Bravia precedent for making tuning feel native; zero new logic since we already send keycodes for digits | High | Trivial (hours) | 2 (fall-out) |
|
||||||
|
| 2 | **`!` app-close prefix on `select_source`/`play_media(app)`** — androidtv precedent (`!com.netflix.ninja`); one-stop fix for "app won't die" automation scripts; zero new deps or surfaces | High | Small (half day incl. tests) | 4 |
|
||||||
|
| 3 | **`play_media(media_content_type: url)` deep links via `send_launch_app_command`** with a cloud-less data-driven *deeplink map option* (validated against the community-wide guide; the library accepts either raw deep link or bare package id) — plus the guide's `adb shell cmd package dump` trick documented for contributors to discover links per app | High | Small–medium (~1 day incl. options editor) | 4 |
|
||||||
|
| 4 | **BrowseMedia "Apps" directory** from running_apps + options-defined app map (androidtv_remote/Apple TV/Bravia precedent) — the single most-requested QoL pattern in this space and the reason users hit external cards ([#117126](https://github.com/home-assistant/core/pull/117126), [HA-Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48)) | High | Medium (~1–2 days incl. icons + tests) | 4 |
|
||||||
|
| 5 | **Volume readout hardening in Phase 2** — `volume_info` push sourced real `volume_level` and mute, with `media_player.volume_set` deliberately unsupported (C-VOL correction, already adopted); if we hear repeated user demand later, offer an ADB-step-loop helper service documented as such (not fake features) | Medium (already handled) | done (plan note only) | 2 |
|
||||||
|
| 6 | **adb_shell streaming_shell probe** — spike: confirm `streaming_shell` exists at our pinned adb-shell version and on the async class; if yes, MVP_PLAN's deferred logcat-on-ADB idea is feasible via a documented generator rather than internal hacks; if no, keeps the snapshot-only plan honest. Cheap spike, high leverage on a currently-"impossible" feature | Medium | Small (half-day spike) | 4 (optional path) |
|
||||||
|
| 7 | **Bravia-style tolerant `media_content_id` matching** for `channel` (accept `5.1`-style floats and exact-name matches from the app/channel map) — small UX win, safe with a strict numeric path retained | Medium | Small | 3/4 |
|
||||||
|
| 8 | **Screen-capture album art (adb_shell `screencap`/`pull`)** — androidtv precedent supplies the pattern; useful only for our PlayOnRoku-missing, Jellyfin-cached apps; keep it as a documented option like androidtv does, since it costs extra ADB shell round trips when playing and is privacy-scope-sensitive | Medium | Medium (~1 day + options slosh) | Post-MVP |
|
||||||
|
| 9 | **IME text entry surfaced as a remote/service integration** — possible now that the library has `send_text`/`text:` syntax, but per #32 behavior is inconsistent; Apple TV solves this with a keyboard-focus push that the Shield protocol doesn't give us cheaply; keep IME an options toggle, not an entity | Low for MVP | Medium | 4 (option only) |
|
||||||
|
| 10 | **`state_detection_rules`-style per-app configurable state ladder** (androidtv pattern) — only if/when users ask for app-specific truth beyond ADB sessions (e.g. IPTV VOD pages); nice future seam; don't build pre-emptively | Low for MVP | Medium–large | Post-MVP |
|
||||||
|
| 11 | **Voice (`start_voice`)** — available in the library, `enable_voice=True` and device-feature-gated; not in our MVP scope, recorded so it's not re-researched | Low | Medium | Post-MVP |
|
||||||
|
|
||||||
|
Key references for the rankings (primary, not forum threads only):
|
||||||
|
[core#176505](https://github.com/home-assistant/core/issues/176505) ·
|
||||||
|
[#117126](https://github.com/home-assistant/core/pull/117126) ·
|
||||||
|
[#17](https://github.com/tronikos/androidtvremote2/issues/17) ·
|
||||||
|
[#32](https://github.com/tronikos/androidtvremote2/issues/32) ·
|
||||||
|
[#28](https://github.com/tronikos/androidtvremote2/issues/28) ·
|
||||||
|
[androidtvremote2 source](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/androidtv_remote.py) ·
|
||||||
|
[proto](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto) ·
|
||||||
|
[adb_shell device](https://github.com/JeffLIrion/adb_shell/blob/master/adb_shell/adb_device.py) ·
|
||||||
|
[Roku](https://www.home-assistant.io/integrations/roku/) ·
|
||||||
|
[Apple TV](https://www.home-assistant.io/integrations/apple_tv/) ·
|
||||||
|
[Bravia](https://www.home-assistant.io/integrations/braviatv/) ·
|
||||||
|
[androidtv](https://www.home-assistant.io/integrations/androidtv/) ·
|
||||||
|
[deep linking guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921/) ·
|
||||||
|
[community TiviMate thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344) ·
|
||||||
|
[Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48) ·
|
||||||
|
[MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Explicit MVP-plan corrections proposed (for Phase-3/4 lands)
|
||||||
|
|
||||||
|
1. **C-VOL (already adopted by captain).** `volume_set` is not protocol-native — the
|
||||||
|
supported features in Phase 2 stay `VOLUME_STEP|MUTE`, with real numeric
|
||||||
|
`volume_level` and `is_volume_muted` readouts from the protocol's `volume_info`
|
||||||
|
push. Absolute-volume demand, if it ever recurs, is addressed honestly via an
|
||||||
|
ADB `input keyevent KEYCODE_VOLUME_*` helper script, documented as such, not as
|
||||||
|
a fake supported feature.
|
||||||
|
2. **C-STREAM (new).** MVP_PLAN §2's statement that adb_shell offers no
|
||||||
|
incremental/streaming read should be softened: `streaming_shell` exists
|
||||||
|
upstream (at least on master) and might make the deferred Phase-4 logcat idea
|
||||||
|
a one-day spike instead of "needs new dependency". Verify our pinned version
|
||||||
|
carries it and whether the async device class exposes it, then either upgrade
|
||||||
|
to a "cheap Phase-4 item" or keep the deferred note with accurate wording.
|
||||||
|
3. **C-LAUNCH (new, informational).** `send_launch_app_command` already accepts a bare
|
||||||
|
package id by auto-prefixing `market://launch?id=`; Phase 4 needs no extra
|
||||||
|
branch for "app" launches, only for deep links (where the value already has a
|
||||||
|
scheme) and `!`-close via ADB (only when the protocol path is not enough).
|
||||||
|
4. **C-TIVI (new).** TiviMate has no usable on-device channel metadata or export
|
||||||
|
surface; the honest MVP display is "on, in TiviMate" + `last_channel` cache
|
||||||
|
from our own play_media calls. Channel-name metadata is a user-supplied
|
||||||
|
playlist/EPG concern, out of scope (and out of ADB's reach).
|
||||||
@@ -394,3 +394,30 @@ media_player:
|
|||||||
9. Migration note: retire mediasession2mqtt sensors + universal-player YAML once
|
9. Migration note: retire mediasession2mqtt sensors + universal-player YAML once
|
||||||
Phase 3 lands (documented in README).
|
Phase 3 lands (documented in README).
|
||||||
10. Netflix stale-session fixture pins "inactive no-state sessions ignored".
|
10. Netflix stale-session fixture pins "inactive no-state sessions ignored".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix — mediasession2mqtt retirement + universal-player glue (Phase 3)
|
||||||
|
|
||||||
|
The fork retires the surrounding glue once the state ladder (README, Phase 3)
|
||||||
|
covers what the external pieces provided:
|
||||||
|
|
||||||
|
| External piece | What it provided | Now native (file) |
|
||||||
|
|---|---|---|
|
||||||
|
| `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the `media_dag_now_playing` attribute carries `source: jellyfin \| media_session \| app` |
|
||||||
|
| Universal media player `media_dag_running_apps` template | app switcher list with playback states | `running_apps_from_tasks(..., last_known=...)` (`ha_adb_common/running_apps.py`) — enriched with `foreground`/`state`/`last_known_state`/`last_known_title` |
|
||||||
|
| Universal player "currently in app X" template | fallback state for no-session apps | the foreground ladder tier (`media_player.py` state property, tier 3.5) |
|
||||||
|
| External androidtv_remote entity for commands | remote keys | the protocol-backed `remote` entity (Phase 2) — the external entity remains only as the transitional last command tier, removed in Phase 4 |
|
||||||
|
| Last-channel / "recently played" sensors | what was on when the session vanished | the TTL-capped last-known-state cache + `_last_channel` + the audio-playback hint (`connection.py`) |
|
||||||
|
|
||||||
|
The migration path: point the automations/scripts at the integration's DAG
|
||||||
|
attributes (`media_dag_*`) instead of the MQTT sensors, then remove
|
||||||
|
`mediasession2mqtt` and the universal-player config entries. No YAML glue is
|
||||||
|
needed for the MVP feature set; the appendix exists so retirement can be
|
||||||
|
staged per household.
|
||||||
|
|
||||||
|
**TiviMate (`ar.tvplayer.tv`)** is the documented no-session case (R2): it
|
||||||
|
publishes no MediaSession on Shield Android 11. Its visibility comes from the
|
||||||
|
foreground tier (tier 3.5) with the audio-playback hint and the
|
||||||
|
last-known-state cache — never from a session, never resurrected past the
|
||||||
|
state TTL.
|
||||||
+4
-7
@@ -14,14 +14,11 @@ dev = [
|
|||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-asyncio>=0.23",
|
"pytest-asyncio>=0.23",
|
||||||
"ruff>=0.4",
|
"ruff>=0.4",
|
||||||
# Shared ADB helpers. Vendored into custom_components/nvidia_shield/ha_adb_common/
|
"androidtvremote2>=0.2.0",
|
||||||
# for HACS installs; used editable from a sibling checkout for development.
|
|
||||||
# Planned migration to a proper PyPI package.
|
|
||||||
"ha-adb-common",
|
|
||||||
]
|
]
|
||||||
|
# The shared ADB helpers are vendored at
|
||||||
[tool.uv.sources]
|
# custom_components/nvidia_shield/ha_adb_common/ — the only consumed copy in
|
||||||
ha-adb-common = { path = "../ha-adb-common", editable = true }
|
# this fork, so no extra dev dependency (and no unavailable sibling checkout).
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Test bootstrap.
|
||||||
|
|
||||||
|
Puts the vendored ha_adb_common package on sys.path so helpers can be imported
|
||||||
|
directly (their code is stdlib-only) without installing Home Assistant or
|
||||||
|
executing the integration's __init__ module.
|
||||||
|
|
||||||
|
The integration-package modules that stay Home-Assistant-free at import time
|
||||||
|
(currently connection.py) are also made importable — via a synthetic package
|
||||||
|
("nsh") that wraps custom_components/nvidia_shield WITHOUT executing its
|
||||||
|
__init__.py (that one imports Home Assistant). Only the stdlib-only modules
|
||||||
|
are loaded into it explicitly; their relative imports resolve normally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "custom_components" / "nvidia_shield"))
|
||||||
|
|
||||||
|
_SHIELD_DIR = REPO_ROOT / "custom_components" / "nvidia_shield"
|
||||||
|
_PKG = "nsh"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_module(name: str, path: Path, search_paths: list[str] | None = None):
|
||||||
|
"""Import a source file under ``name`` without touching real packages."""
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
name, path, submodule_search_locations=search_paths
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
# Synthetic integration package — no __init__.py execution (HA-free).
|
||||||
|
_root = types.ModuleType(_PKG)
|
||||||
|
_root.__path__ = [str(_SHIELD_DIR)]
|
||||||
|
sys.modules[_PKG] = _root
|
||||||
|
|
||||||
|
# Real subpackage __init__ (stdlib-only) so `from .ha_adb_common import ...`
|
||||||
|
# works through the synthetic package.
|
||||||
|
_load_module(
|
||||||
|
f"{_PKG}.ha_adb_common",
|
||||||
|
_SHIELD_DIR / "ha_adb_common" / "__init__.py",
|
||||||
|
[str(_SHIELD_DIR / "ha_adb_common")],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stdlib-only integration modules.
|
||||||
|
for _mod in ("const", "adb_session", "connection"):
|
||||||
|
_load_module(f"{_PKG}.{_mod}", _SHIELD_DIR / f"{_mod}.py")
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
"""Home-Assistant object stubs sized to unit-test the entity modules (t10).
|
||||||
|
|
||||||
|
Import-only stubs: MediaPlayerEntity/RemoteEntity bases, the feature/state
|
||||||
|
enum values the integration actually uses, and the event helpers. Tests load
|
||||||
|
their entity module through the synthetic ``nsh`` package AFTER calling
|
||||||
|
:func:`install` (and the per-file fixture pops everything at teardown).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import enum
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
import voluptuous_mirror
|
||||||
|
|
||||||
|
ENTITIES_STUB_NAMES = (
|
||||||
|
"voluptuous",
|
||||||
|
"homeassistant",
|
||||||
|
"homeassistant.config_entries",
|
||||||
|
"homeassistant.exceptions",
|
||||||
|
"homeassistant.core",
|
||||||
|
"homeassistant.const",
|
||||||
|
"homeassistant.components",
|
||||||
|
"homeassistant.components.media_player",
|
||||||
|
"homeassistant.components.remote",
|
||||||
|
"homeassistant.components.button",
|
||||||
|
"homeassistant.helpers",
|
||||||
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
|
"homeassistant.helpers.selector",
|
||||||
|
"homeassistant.helpers.event",
|
||||||
|
"homeassistant.helpers.redact",
|
||||||
|
"homeassistant.helpers.entity",
|
||||||
|
"homeassistant.helpers.entity_platform",
|
||||||
|
"nsh.helpers",
|
||||||
|
"nsh.power",
|
||||||
|
"nsh.jellyfin",
|
||||||
|
"nsh.media_player",
|
||||||
|
"nsh.diagnostics",
|
||||||
|
"nsh.remote",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPlayerState(enum.StrEnum):
|
||||||
|
OFF = "off"
|
||||||
|
ON = "on"
|
||||||
|
IDLE = "idle"
|
||||||
|
PLAYING = "playing"
|
||||||
|
PAUSED = "paused"
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPlayerEntityFeature(enum.IntFlag):
|
||||||
|
PLAY = 1
|
||||||
|
PAUSE = 2
|
||||||
|
STOP = 4
|
||||||
|
VOLUME_MUTE = 8
|
||||||
|
VOLUME_STEP = 16
|
||||||
|
VOLUME_SET = 32
|
||||||
|
BROWSE_MEDIA = 4096
|
||||||
|
TURN_ON = 64
|
||||||
|
TURN_OFF = 128
|
||||||
|
PLAY_MEDIA = 256
|
||||||
|
NEXT_TRACK = 512
|
||||||
|
PREVIOUS_TRACK = 1024
|
||||||
|
|
||||||
|
|
||||||
|
class MediaClass:
|
||||||
|
"""homeassistant.components.media_player.MediaClass (values we use)."""
|
||||||
|
|
||||||
|
APP = "app"
|
||||||
|
DIRECTORY = "directory"
|
||||||
|
|
||||||
|
|
||||||
|
class MediaType:
|
||||||
|
"""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__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
media_class,
|
||||||
|
media_content_id,
|
||||||
|
media_content_type,
|
||||||
|
title,
|
||||||
|
can_play,
|
||||||
|
can_expand,
|
||||||
|
children=None,
|
||||||
|
children_media_class=None,
|
||||||
|
thumbnail=None,
|
||||||
|
not_shown=0,
|
||||||
|
can_search=False,
|
||||||
|
search_media_classes=None,
|
||||||
|
):
|
||||||
|
self.media_class = media_class
|
||||||
|
self.media_content_id = media_content_id
|
||||||
|
self.media_content_type = media_content_type
|
||||||
|
self.title = title
|
||||||
|
self.can_play = can_play
|
||||||
|
self.can_expand = can_expand
|
||||||
|
self.children = children
|
||||||
|
self.children_media_class = children_media_class
|
||||||
|
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:
|
||||||
|
"""trivial stand-in for the HA platform internals we don't exercise."""
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPlayerEntity:
|
||||||
|
"""Base entity: attributes only; state writing is a no-op."""
|
||||||
|
|
||||||
|
def async_write_ha_state(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteEntity:
|
||||||
|
def async_write_ha_state(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def install() -> None:
|
||||||
|
"""Register the stub modules (idempotent)."""
|
||||||
|
if "homeassistant.components.media_player" in sys.modules:
|
||||||
|
return
|
||||||
|
|
||||||
|
vol = types.ModuleType("voluptuous")
|
||||||
|
# t33 F-A: the faithful mirror keeps schema defaults visible — a bare-key
|
||||||
|
# stub cannot tell a schema WITH a default from one without.
|
||||||
|
voluptuous_mirror.install(vol)
|
||||||
|
sys.modules["voluptuous"] = vol
|
||||||
|
|
||||||
|
ha = types.ModuleType("homeassistant")
|
||||||
|
sys.modules["homeassistant"] = ha
|
||||||
|
|
||||||
|
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||||
|
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(
|
||||||
|
"ConfigFlow",
|
||||||
|
(_ConfigFlowStub,),
|
||||||
|
{
|
||||||
|
"__init_subclass__": classmethod(
|
||||||
|
lambda cls, domain=None, **kwargs: None
|
||||||
|
),
|
||||||
|
"__init__": _config_flow_init,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
config_entries.ConfigFlow = _config_flow_base
|
||||||
|
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
|
||||||
|
|
||||||
|
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.HomeAssistant = object
|
||||||
|
core.callback = lambda fn: fn
|
||||||
|
core.CALLBACK_TYPE = object
|
||||||
|
core.Event = object
|
||||||
|
sys.modules["homeassistant.core"] = core
|
||||||
|
|
||||||
|
ha_const = types.ModuleType("homeassistant.const")
|
||||||
|
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||||
|
ha_const.STATE_PLAYING = "playing"
|
||||||
|
ha_const.STATE_PAUSED = "paused"
|
||||||
|
ha_const.Platform = types.SimpleNamespace(
|
||||||
|
MEDIA_PLAYER="media_player",
|
||||||
|
SENSOR="sensor",
|
||||||
|
REMOTE="remote",
|
||||||
|
BUTTON="button",
|
||||||
|
)
|
||||||
|
sys.modules["homeassistant.const"] = ha_const
|
||||||
|
|
||||||
|
components = types.ModuleType("homeassistant.components")
|
||||||
|
components.__path__ = []
|
||||||
|
sys.modules["homeassistant.components"] = components
|
||||||
|
|
||||||
|
mp = types.ModuleType("homeassistant.components.media_player")
|
||||||
|
mp.MediaPlayerEntity = MediaPlayerEntity
|
||||||
|
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
|
||||||
|
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
|
||||||
|
mp.BrowseMedia = BrowseMedia
|
||||||
|
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
|
||||||
|
sys.modules["homeassistant.components.media_player"] = mp
|
||||||
|
|
||||||
|
remote = types.ModuleType("homeassistant.components.remote")
|
||||||
|
remote.RemoteEntity = RemoteEntity
|
||||||
|
sys.modules["homeassistant.components.remote"] = remote
|
||||||
|
|
||||||
|
button = types.ModuleType("homeassistant.components.button")
|
||||||
|
button.ButtonEntity = type("ButtonEntity", (), {})
|
||||||
|
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")
|
||||||
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
entities: dict = {}
|
||||||
|
|
||||||
|
def async_get(self, entity_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
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
|
||||||
|
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.async_call_later = lambda hass, delay, callback_fn: (lambda: None)
|
||||||
|
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
|
||||||
|
helpers.event = event
|
||||||
|
helpers.selector = types.SimpleNamespace(
|
||||||
|
EntitySelector=lambda *a, **k: None,
|
||||||
|
EntitySelectorConfig=lambda *a, **k: "",
|
||||||
|
SelectSelector=lambda *a, **k: None,
|
||||||
|
SelectSelectorConfig=lambda **k: "",
|
||||||
|
SelectSelectorMode=types.SimpleNamespace(DROPDOWN="dropdown"),
|
||||||
|
)
|
||||||
|
entity = types.ModuleType("homeassistant.helpers.entity")
|
||||||
|
entity.DeviceInfo = dict
|
||||||
|
entity.EntityCategory = types.SimpleNamespace(
|
||||||
|
CONFIG="config", DIAGNOSTIC="diagnostic"
|
||||||
|
)
|
||||||
|
helpers.entity = entity
|
||||||
|
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
|
||||||
|
entity_platform.AddEntitiesCallback = object
|
||||||
|
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.entity"] = entity
|
||||||
|
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
|
||||||
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
|
||||||
|
def load_entity_modules() -> dict:
|
||||||
|
"""Load the integration's HA-free-ish entity modules under nsh."""
|
||||||
|
install()
|
||||||
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
|
|
||||||
|
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:
|
||||||
|
file_name = "__init__.py" if name == "init" else f"{name}.py"
|
||||||
|
_load_module(f"nsh.{name}", _SHIELD_DIR / file_name)
|
||||||
|
return {
|
||||||
|
name: sys.modules[f"nsh.{name}"]
|
||||||
|
for name in (
|
||||||
|
"power",
|
||||||
|
"jellyfin",
|
||||||
|
"sensor",
|
||||||
|
"media_player",
|
||||||
|
"remote",
|
||||||
|
"diagnostics",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHass:
|
||||||
|
"""Sized to entity unit tests: background tasks + no-op call_later."""
|
||||||
|
|
||||||
|
def async_create_task(self, coro):
|
||||||
|
return asyncio.create_task(coro)
|
||||||
|
|
||||||
|
def async_call_later(self, delay, callback_fn):
|
||||||
|
return lambda: None
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
|
||||||
|
state=RESUMED stopped=false delayedResume=false finishing=false
|
||||||
|
Resumed activities in task display areas (from top to bottom):
|
||||||
|
Resumed: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
|
||||||
|
ResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
SYNTHETIC fixture — not a real device capture. The real Shield `dumpsys
|
||||||
|
activity recents` capture (R6) is still outstanding; this file pins the
|
||||||
|
parsing contract of AdbClientBase.poll_running_apps in the meantime, covering
|
||||||
|
both the A=uid:package and the I=package/activity fallback patterns.
|
||||||
|
Represents the post-grep output of:
|
||||||
|
dumpsys activity recents 2>/dev/null | grep -E 'Recent #|realActivity|visible='
|
||||||
|
Note: line order = recency order (most recently used first).
|
||||||
|
* Recent #0: Task{2c9f81f #817 type=standard A=10185:ar.tvplayer.tv I=ar.tvplayer.tv/.ui.MainActivity ... visible=true numActivities=1 ...}
|
||||||
|
* Recent #1: Task{3dae20b #802 type=standard A=10106:com.github.damontecres.wholphin ... visible=false numActivities=1 ...}
|
||||||
|
* Recent #2: Task{4b7ce01 #788 type=standard I=com.example.launcherapp/.ui.HomeActivity ... visible=true ...}
|
||||||
|
* Recent #3: Task{5c8dd12 #770 type=standard A=1000:com.android.systemui ... visible=true ...}
|
||||||
|
* Recent #4: Task{6d9ef23 #750 type=unknown A=10003:.HiddenInternalTask ... visible=true ...}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mWakefulness=Asleep
|
||||||
|
mWakefulnessChanging=false
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mWakefulness=Awake
|
||||||
|
mWakefulnessChanging=false
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
MEDIA SESSION SERVICE (dumpsys media_session)
|
||||||
|
|
||||||
|
9 sessions listeners.
|
||||||
|
Global priority session is null
|
||||||
|
User Records:
|
||||||
|
Record for full_user=0
|
||||||
|
Volume key long-press listener: null
|
||||||
|
Volume key long-press listener package:
|
||||||
|
Media key listener: null
|
||||||
|
Media key listener package:
|
||||||
|
OnMediaKeyEventDispatchedListener: added 0 listener(s)
|
||||||
|
OnMediaKeyEventSessionChangedListener: added 1 listener(s)
|
||||||
|
from com.android.bluetooth
|
||||||
|
Last MediaButtonReceiver: null
|
||||||
|
Media button session is null
|
||||||
|
Sessions Stack - have 1 sessions:
|
||||||
|
Netflix media session com.netflix.ninja/Netflix media session (userId=0)
|
||||||
|
ownerPid=5833, ownerUid=10080, userId=0
|
||||||
|
package=com.netflix.ninja
|
||||||
|
launchIntent=null
|
||||||
|
mediaButtonReceiver=null
|
||||||
|
active=false
|
||||||
|
flags=3
|
||||||
|
rating type=0
|
||||||
|
controllers: 0
|
||||||
|
state=null
|
||||||
|
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_UNKNOWN flags=0x800 tags= bundle=null
|
||||||
|
volumeType=1, controlType=2, max=0, current=0
|
||||||
|
metadata: null
|
||||||
|
queueTitle=null, size=0
|
||||||
|
Audio playback (lastly played comes first)
|
||||||
|
uid=10117 packages=ar.tvplayer.tv
|
||||||
|
uid=10006 packages=com.google.android.apps.mediashell
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
MEDIA SESSION SERVICE (dumpsys media_session)
|
||||||
|
|
||||||
|
9 sessions listeners.
|
||||||
|
User Records:
|
||||||
|
Record for full_user=0
|
||||||
|
Media button session is null
|
||||||
|
Sessions Stack - have 1 sessions:
|
||||||
|
Wholphin media session com.github.damontecres.wholphin/Wholphin media session (userId=0)
|
||||||
|
ownerPid=6117, ownerUid=10117, userId=0
|
||||||
|
package=com.github.damontecres.wholphin
|
||||||
|
launchIntent=null
|
||||||
|
mediaButtonReceiver=null
|
||||||
|
active=true
|
||||||
|
flags=3
|
||||||
|
rating type=0
|
||||||
|
controllers: 1
|
||||||
|
state=PlaybackState {state=3, position=17237, buffered position=70070, speed=1.0, updated=1408392, actions=7339995, custom actions=[], active item id=0, error=null}
|
||||||
|
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_MOVIE flags=0x800 tags= bundle=null
|
||||||
|
volumeType=1, controlType=2, max=0, current=0
|
||||||
|
metadata: size=11, description=Twirlywoos, null, null
|
||||||
|
queueTitle=null, size=1
|
||||||
|
Audio playback (lastly played comes first)
|
||||||
|
uid=10117 packages=com.github.damontecres.wholphin
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Tests for AdbClientBase additions: exact wakefulness parse (R5),
|
||||||
|
health_check(), and optional media-session truncation (plan blocker B4).
|
||||||
|
|
||||||
|
The client runs against a FakeDevice recording shell commands — no real ADB
|
||||||
|
socket or adb_shell import is needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ha_adb_common.adb_client import AdbClientBase, parse_wakefulness
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
CMD_WAKEFULNESS = "dumpsys power 2>/dev/null | grep mWakefulness"
|
||||||
|
CMD_MEDIA_DEFAULT = "dumpsys media_session 2>/dev/null | head -200"
|
||||||
|
CMD_MEDIA_FULL = "dumpsys media_session 2>/dev/null"
|
||||||
|
CMD_ECHO_OK = "echo ok"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDevice:
|
||||||
|
"""Records shell commands and returns canned responses per command."""
|
||||||
|
|
||||||
|
def __init__(self, responses=None):
|
||||||
|
self._responses = dict(responses or {})
|
||||||
|
self.commands: list[str] = []
|
||||||
|
|
||||||
|
async def shell(self, cmd: str) -> str | None:
|
||||||
|
self.commands.append(cmd)
|
||||||
|
response = self._responses.get(cmd)
|
||||||
|
if isinstance(response, Exception):
|
||||||
|
raise response
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _client_response(cmd: str, response: str | None) -> AdbClientBase:
|
||||||
|
"""A client whose (fake) device is already connected and answers cmd."""
|
||||||
|
client = AdbClientBase("shield.local", 5555, "/dev/null/adbkey")
|
||||||
|
client._device = FakeDevice({cmd: response})
|
||||||
|
client._connected = True # skip connect(): no adb_shell in the test env
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseWakefulness:
|
||||||
|
"""Exact-value mWakefulness= parse; mWakefulnessChanging excluded."""
|
||||||
|
|
||||||
|
def test_awake_fixture(self):
|
||||||
|
text = (FIXTURES / "dumpsys_power_wakefulness_awake.txt").read_text()
|
||||||
|
assert parse_wakefulness(text) is True
|
||||||
|
|
||||||
|
def test_asleep_fixture(self):
|
||||||
|
text = (FIXTURES / "dumpsys_power_wakefulness_asleep.txt").read_text()
|
||||||
|
assert parse_wakefulness(text) is False
|
||||||
|
|
||||||
|
def test_known_asleep_values(self):
|
||||||
|
assert parse_wakefulness("mWakefulness=Asleep") is False
|
||||||
|
assert parse_wakefulness("mWakefulness=Dozing") is False
|
||||||
|
assert parse_wakefulness("mWakefulness=Dozing-suspend") is False
|
||||||
|
|
||||||
|
def test_unknown_value_is_unknown_not_awake(self):
|
||||||
|
"""The old substring heuristic read any future value containing no
|
||||||
|
'Awake' as asleep; unrecognized values must be None (captain's note)."""
|
||||||
|
assert parse_wakefulness("mWakefulness=Dozing-ext") is None
|
||||||
|
assert parse_wakefulness("mWakefulness=123") is None
|
||||||
|
|
||||||
|
def test_changing_and_setting_lines_are_ignored(self):
|
||||||
|
assert (
|
||||||
|
parse_wakefulness(" mWakefulnessChanging=false") is None
|
||||||
|
)
|
||||||
|
assert parse_wakefulness(" mWakefulnessSetting=2") is None
|
||||||
|
|
||||||
|
def test_assignment_wins_over_changing_line(self):
|
||||||
|
text = " mWakefulnessChanging=false\nmWakefulness=Awake\n"
|
||||||
|
assert parse_wakefulness(text) is True
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
assert parse_wakefulness("") is None
|
||||||
|
assert parse_wakefulness(None) is None
|
||||||
|
assert parse_wakefulness("no match here") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsAwake:
|
||||||
|
async def test_awake(self):
|
||||||
|
client = _client_response(
|
||||||
|
CMD_WAKEFULNESS,
|
||||||
|
(FIXTURES / "dumpsys_power_wakefulness_awake.txt").read_text(),
|
||||||
|
)
|
||||||
|
assert await client.is_awake() is True
|
||||||
|
assert client._device.commands == [CMD_WAKEFULNESS]
|
||||||
|
|
||||||
|
async def test_asleep(self):
|
||||||
|
client = _client_response(
|
||||||
|
CMD_WAKEFULNESS,
|
||||||
|
(FIXTURES / "dumpsys_power_wakefulness_asleep.txt").read_text(),
|
||||||
|
)
|
||||||
|
assert await client.is_awake() is False
|
||||||
|
|
||||||
|
async def test_unknown_reports_none(self):
|
||||||
|
"""A garbage/empty grep result parses to None, not False."""
|
||||||
|
client = _client_response(CMD_WAKEFULNESS, "")
|
||||||
|
assert await client.is_awake() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthCheck:
|
||||||
|
async def test_echo_ok_round_trip_is_healthy(self):
|
||||||
|
client = _client_response(CMD_ECHO_OK, "ok\n")
|
||||||
|
assert await client.health_check() is True
|
||||||
|
|
||||||
|
async def test_failed_shell_is_unhealthy(self):
|
||||||
|
client = _client_response(CMD_ECHO_OK, Exception("socket closed"))
|
||||||
|
assert await client.health_check() is False
|
||||||
|
assert client.connected is False # shell() marked the socket broken
|
||||||
|
|
||||||
|
async def test_wrong_body_is_unhealthy(self):
|
||||||
|
client = _client_response(CMD_ECHO_OK, "something else\n")
|
||||||
|
assert await client.health_check() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollMediaSessionsTruncation:
|
||||||
|
async def test_default_truncates_at_200(self):
|
||||||
|
client = _client_response(CMD_MEDIA_DEFAULT, "")
|
||||||
|
assert await client.poll_media_sessions() is None
|
||||||
|
assert client._device.commands == [CMD_MEDIA_DEFAULT]
|
||||||
|
|
||||||
|
async def test_explicit_head_lines(self):
|
||||||
|
client = _client_response("dumpsys media_session 2>/dev/null | head -80", "")
|
||||||
|
await client.poll_media_sessions(head_lines=80)
|
||||||
|
assert client._device.commands == [
|
||||||
|
"dumpsys media_session 2>/dev/null | head -80"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_none_head_lines_fetches_full_dump(self):
|
||||||
|
"""None = no truncation — the manager later passes None (B4)."""
|
||||||
|
wholphin = (FIXTURES / "media_session_wholphin.txt").read_text()
|
||||||
|
client = _client_response(CMD_MEDIA_FULL, wholphin)
|
||||||
|
info = await client.poll_media_sessions(head_lines=None)
|
||||||
|
assert client._device.commands == [CMD_MEDIA_FULL]
|
||||||
|
assert info is not None
|
||||||
|
assert info.package == "com.github.damontecres.wholphin"
|
||||||
|
assert info.position == 17237
|
||||||
|
assert client.all_sessions[0].playing is True
|
||||||
|
|
||||||
|
async def test_failed_shell_returns_none_without_reparsing(self):
|
||||||
|
client = _client_response(CMD_MEDIA_DEFAULT, Exception("dead"))
|
||||||
|
assert await client.poll_media_sessions() is None
|
||||||
|
assert client.last_info is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollRunningApps:
|
||||||
|
"""Recent-task parsing against the SYNTHETIC recents fixture.
|
||||||
|
|
||||||
|
The real Shield `dumpsys activity recents` capture (R6) is still
|
||||||
|
outstanding; tests/fixtures/activity_recents_synthetic.txt pins the
|
||||||
|
parsing contract in the meantime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_parses_visible_user_apps_in_recency_order(self):
|
||||||
|
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
|
||||||
|
client = _client_response(
|
||||||
|
"dumpsys activity recents 2>/dev/null "
|
||||||
|
"| grep -E 'Recent #|realActivity|visible='",
|
||||||
|
recents,
|
||||||
|
)
|
||||||
|
apps = await client.poll_running_apps()
|
||||||
|
assert [a["package"] for a in apps] == [
|
||||||
|
"ar.tvplayer.tv",
|
||||||
|
"com.example.launcherapp",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_i_pattern_fallback_used_without_a_uid_assignment(self):
|
||||||
|
"""`I=com.example.launcherapp/.ui.HomeActivity` is the only package
|
||||||
|
fragment on its line and must be picked up by the fallback regex."""
|
||||||
|
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
|
||||||
|
client = _client_response(
|
||||||
|
"dumpsys activity recents 2>/dev/null "
|
||||||
|
"| grep -E 'Recent #|realActivity|visible='",
|
||||||
|
recents,
|
||||||
|
)
|
||||||
|
apps = await client.poll_running_apps()
|
||||||
|
assert "com.example.launcherapp" in {a["package"] for a in apps}
|
||||||
|
|
||||||
|
async def test_skips_invisible_tasks_system_packages_and_dot_names(self):
|
||||||
|
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
|
||||||
|
client = _client_response(
|
||||||
|
"dumpsys activity recents 2>/dev/null "
|
||||||
|
"| grep -E 'Recent #|realActivity|visible='",
|
||||||
|
recents,
|
||||||
|
)
|
||||||
|
apps = await client.poll_running_apps()
|
||||||
|
packages = {a["package"] for a in apps}
|
||||||
|
assert "com.github.damontecres.wholphin" not in packages # visible=false
|
||||||
|
assert "com.android.systemui" not in packages # excluded system UI
|
||||||
|
assert ".HiddenInternalTask" not in packages # dot-named internals
|
||||||
|
|
||||||
|
async def test_empty_result_returns_empty_list(self):
|
||||||
|
client = _client_response(
|
||||||
|
"dumpsys activity recents 2>/dev/null "
|
||||||
|
"| grep -E 'Recent #|realActivity|visible='",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert await client.poll_running_apps() == []
|
||||||
|
|
||||||
|
async def test_failed_shell_returns_empty_list(self):
|
||||||
|
client = _client_response(
|
||||||
|
"dumpsys activity recents 2>/dev/null "
|
||||||
|
"| grep -E 'Recent #|realActivity|visible='",
|
||||||
|
Exception("offline"),
|
||||||
|
)
|
||||||
|
assert await client.poll_running_apps() == []
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,245 @@
|
|||||||
|
"""F1 regression: the adb_pair submit path calls
|
||||||
|
ShieldConnectionManager.test_connect with its declared signature
|
||||||
|
(host, port, key_path) — nothing extra, arity exact.
|
||||||
|
|
||||||
|
config_flow.py imports Home Assistant; it is exercised here against small
|
||||||
|
module stubs (import-only; no HA behaviour needed on this path) with the
|
||||||
|
actual ADB handshake mocked on the manager.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
|
from nsh.const import (
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
CONF_ADB_STORE_APP_INFO,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_JELLYFIN_ENTITY,
|
||||||
|
CONF_POWER_ENTITY,
|
||||||
|
CONF_RECEIVER_ENTITY,
|
||||||
|
CONF_RECEIVER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
_KEY_PATH = "/tmp/nsh-test-adbkey"
|
||||||
|
|
||||||
|
|
||||||
|
_STUB_NAMES = (
|
||||||
|
"voluptuous",
|
||||||
|
"homeassistant",
|
||||||
|
"homeassistant.config_entries",
|
||||||
|
"homeassistant.core",
|
||||||
|
"homeassistant.helpers",
|
||||||
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
|
"homeassistant.helpers.selector",
|
||||||
|
# Loaded execution runs beneath the stubs; never escape this file either.
|
||||||
|
"nsh.helpers",
|
||||||
|
"nsh.config_flow",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_import_stubs() -> None:
|
||||||
|
"""Register import-time stand-ins for HA/voluptuous (idempotent)."""
|
||||||
|
if sys.modules.get("voluptuous") is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
vol = types.ModuleType("voluptuous")
|
||||||
|
vol.Required = lambda key, default=None: key
|
||||||
|
vol.Optional = lambda key, default=None: key
|
||||||
|
vol.Schema = lambda schema: schema
|
||||||
|
sys.modules["voluptuous"] = vol
|
||||||
|
|
||||||
|
ha = types.ModuleType("homeassistant")
|
||||||
|
sys.modules["homeassistant"] = ha
|
||||||
|
|
||||||
|
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||||
|
|
||||||
|
class ConfigFlow:
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs):
|
||||||
|
kwargs.pop("domain", None) # class statement passes domain=DOMAIN
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
|
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||||
|
return _form(step_id, errors or {})
|
||||||
|
|
||||||
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
|
return {"type": "create_entry", "title": title, "data": data,
|
||||||
|
"options": options}
|
||||||
|
|
||||||
|
def _form(step_id, errors):
|
||||||
|
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
||||||
|
|
||||||
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
|
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
|
||||||
|
|
||||||
|
core = types.ModuleType("homeassistant.core")
|
||||||
|
core.HomeAssistant = object
|
||||||
|
core.callback = lambda fn: fn
|
||||||
|
sys.modules["homeassistant.core"] = core
|
||||||
|
|
||||||
|
helpers = types.ModuleType("homeassistant.helpers")
|
||||||
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
entities: dict = {}
|
||||||
|
|
||||||
|
def async_get(self, entity_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
entity_registry.async_get = lambda hass: _Registry()
|
||||||
|
helpers.entity_registry = entity_registry
|
||||||
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
|
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.EntitySelector = lambda *a, **k: None
|
||||||
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||||
|
selector.SelectSelector = lambda *a, **k: None
|
||||||
|
selector.SelectSelectorConfig = lambda **k: ""
|
||||||
|
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||||
|
sys.modules["homeassistant.helpers.selector"] = selector
|
||||||
|
|
||||||
|
|
||||||
|
class _Config:
|
||||||
|
config_dir = "/tmp/nsh-test-config"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHass:
|
||||||
|
config = _Config
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def async_add_executor_job(fn, *args):
|
||||||
|
return fn(*args)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_flow_module():
|
||||||
|
_install_import_stubs()
|
||||||
|
if "nsh.helpers" not in sys.modules:
|
||||||
|
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
|
||||||
|
if "nsh.config_flow" not in sys.modules:
|
||||||
|
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
|
||||||
|
return sys.modules["nsh.config_flow"]
|
||||||
|
|
||||||
|
|
||||||
|
def _flow(user_input_host: str, monkeypatch=None):
|
||||||
|
module = _config_flow_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = _FakeHass
|
||||||
|
flow._user_data = {
|
||||||
|
"name": "Nvidia Shield",
|
||||||
|
CONF_ADB_ENABLED: True,
|
||||||
|
CONF_ADB_HOST: user_input_host,
|
||||||
|
CONF_ADB_PORT: 5555,
|
||||||
|
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
||||||
|
CONF_JELLYFIN_ENTITY: "",
|
||||||
|
CONF_RECEIVER_ENTITY: "",
|
||||||
|
CONF_RECEIVER_INPUT: "",
|
||||||
|
CONF_POWER_ENTITY: "",
|
||||||
|
CONF_ADB_STORE_APP_INFO: True,
|
||||||
|
}
|
||||||
|
if monkeypatch is not None:
|
||||||
|
# Patch module-level resolution — the call site is a plain global.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module, "ensure_adb_key", lambda _config_dir: _KEY_PATH
|
||||||
|
)
|
||||||
|
return flow
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def scoped_stubs():
|
||||||
|
"""Contain the import stubs to this module's tests (t7 INFO).
|
||||||
|
|
||||||
|
Snapshots every injected sys.modules entry and restores or removes it on
|
||||||
|
teardown, so later tests never observe the fake homeassistant/voluptuous
|
||||||
|
(nor the stub-loaded nsh.helpers/nsh.config_flow) modules.
|
||||||
|
"""
|
||||||
|
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
|
||||||
|
_install_import_stubs()
|
||||||
|
yield
|
||||||
|
for name in _STUB_NAMES:
|
||||||
|
original = snapshot[name]
|
||||||
|
if original is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mocked_test_connect(monkeypatch):
|
||||||
|
from nsh.connection import ShieldConnectionManager
|
||||||
|
|
||||||
|
mock = AsyncMock(return_value=True)
|
||||||
|
monkeypatch.setattr(ShieldConnectionManager, "test_connect", mock)
|
||||||
|
# Preload + patch the lazy-loaded config-flow module's ensure_adb_key
|
||||||
|
# (no key material — and no adb_shell keygen — during tests).
|
||||||
|
module = _config_flow_module()
|
||||||
|
monkeypatch.setattr(module, "ensure_adb_key", lambda _config_dir: _KEY_PATH)
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
|
||||||
|
"""F1 (HIGH): exactly (host, port, key_path) — no inherited hass arg."""
|
||||||
|
flow = _flow("shield.local")
|
||||||
|
|
||||||
|
result = await flow.async_step_adb_pair({"submit": True})
|
||||||
|
|
||||||
|
mocked_test_connect.assert_awaited_once_with(
|
||||||
|
"shield.local", 5555, _KEY_PATH
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pairing_submit_reports_adb_failed_on_false(mocked_test_connect):
|
||||||
|
flow = _flow("shield.local")
|
||||||
|
mocked_test_connect.return_value = False
|
||||||
|
|
||||||
|
result = await flow.async_step_adb_pair({"submit": True})
|
||||||
|
|
||||||
|
mocked_test_connect.assert_awaited_once()
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "adb_failed"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pairing_submit_without_host_reports_error(mocked_test_connect):
|
||||||
|
flow = _flow("") # no host resolvable
|
||||||
|
|
||||||
|
result = await flow.async_step_adb_pair({"submit": True})
|
||||||
|
|
||||||
|
mocked_test_connect.assert_not_awaited()
|
||||||
|
assert result["errors"] == {"base": "adb_no_host"}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"""Phase 2 step 2 tests: protocol cert/PIN pairing, reauth, entry migration.
|
||||||
|
|
||||||
|
Self-contained scoped-stub variant of the config-flow test harness (same
|
||||||
|
pattern as test_config_flow_pairing.py, with pair-hygiene capabilities added:
|
||||||
|
FakeHass records unique_id sets, entry updates and reload scheduling). The
|
||||||
|
androidtvremote2 client is faked with a deterministic pairing code and MAC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio # noqa: F401 (async steps need the event loop)
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
|
from nsh.const import (
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
CONF_ADB_STORE_APP_INFO,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_JELLYFIN_ENTITY,
|
||||||
|
CONF_PAIRING_CODE,
|
||||||
|
CONF_POWER_ENTITY,
|
||||||
|
CONF_PROTOCOL_ENABLED,
|
||||||
|
CONF_PROTOCOL_HOST,
|
||||||
|
CONF_RECEIVER_ENTITY,
|
||||||
|
CONF_RECEIVER_INPUT,
|
||||||
|
DEFAULT_ADB_PORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
_STUB_NAMES = (
|
||||||
|
"voluptuous",
|
||||||
|
"homeassistant",
|
||||||
|
"homeassistant.config_entries",
|
||||||
|
"homeassistant.core",
|
||||||
|
"homeassistant.const",
|
||||||
|
"homeassistant.helpers",
|
||||||
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
|
"homeassistant.helpers.selector",
|
||||||
|
"nsh.helpers",
|
||||||
|
"nsh.config_flow",
|
||||||
|
"nsh.entry",
|
||||||
|
)
|
||||||
|
|
||||||
|
FAKE_PAIRING_CODE = "8421"
|
||||||
|
FAKE_DEVICE_NAME = "mdarcy"
|
||||||
|
FAKE_MAC = "aa:bb:cc:dd:ee:ff"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeInvalidAuthError(Exception):
|
||||||
|
"""androidtvremote2.exceptions.InvalidAuth stand-in."""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProtocolClient:
|
||||||
|
"""Proto client used by the flow pairing path (deterministic)."""
|
||||||
|
|
||||||
|
def __init__(self, fail_code=False):
|
||||||
|
self.cert_generated = False
|
||||||
|
self.pairing_started = False
|
||||||
|
self.finished_codes: list[str] = []
|
||||||
|
self.disconnected = 0
|
||||||
|
self._fail_code = fail_code
|
||||||
|
|
||||||
|
async def async_generate_cert_if_missing(self):
|
||||||
|
self.cert_generated = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def async_start_pairing(self):
|
||||||
|
self.pairing_started = True
|
||||||
|
return FAKE_PAIRING_CODE # deterministic "device-side PIN"
|
||||||
|
|
||||||
|
async def async_finish_pairing(self, pairing_code: str) -> None:
|
||||||
|
if self._fail_code or pairing_code != FAKE_PAIRING_CODE:
|
||||||
|
raise FakeInvalidAuthError("device rejected the pairing code")
|
||||||
|
self.finished_codes.append(pairing_code)
|
||||||
|
|
||||||
|
async def async_get_name_and_mac(self):
|
||||||
|
return FAKE_DEVICE_NAME, FAKE_MAC
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.disconnected += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _install_stubs() -> None:
|
||||||
|
"""Import-time stand-ins (idempotent, scoped by the autouse fixture)."""
|
||||||
|
if sys.modules.get("voluptuous") is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
vol = types.ModuleType("voluptuous")
|
||||||
|
vol.Required = lambda key, default=None: key
|
||||||
|
vol.Optional = lambda key, default=None: key
|
||||||
|
vol.Schema = lambda schema: schema
|
||||||
|
sys.modules["voluptuous"] = vol
|
||||||
|
|
||||||
|
ha = types.ModuleType("homeassistant")
|
||||||
|
sys.modules["homeassistant"] = ha
|
||||||
|
|
||||||
|
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||||
|
|
||||||
|
class ConfigFlow:
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs):
|
||||||
|
kwargs.pop("domain", None)
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
|
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||||
|
return _form(step_id, errors or {})
|
||||||
|
|
||||||
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
|
return {"type": "create_entry", "title": title, "data": data,
|
||||||
|
"options": options}
|
||||||
|
|
||||||
|
def async_abort(self, *, reason):
|
||||||
|
return {"type": "abort", "reason": reason}
|
||||||
|
|
||||||
|
async def async_set_unique_id(self, unique_id):
|
||||||
|
self._unique_id = unique_id
|
||||||
|
|
||||||
|
def _abort_if_unique_id_configured(self, *, error=None, **kwargs):
|
||||||
|
if getattr(self, "_unique_id", None) in active_unique_ids:
|
||||||
|
raise AbortIfConfiguredError()
|
||||||
|
|
||||||
|
def _form(step_id, errors):
|
||||||
|
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
||||||
|
|
||||||
|
class AbortIfConfiguredError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
active_unique_ids = set()
|
||||||
|
|
||||||
|
class OptionsFlow:
|
||||||
|
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.OptionsFlow = OptionsFlow
|
||||||
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||||
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
|
core = types.ModuleType("homeassistant.core")
|
||||||
|
core.HomeAssistant = object
|
||||||
|
core.callback = lambda fn: fn
|
||||||
|
sys.modules["homeassistant.core"] = core
|
||||||
|
|
||||||
|
ha_const = types.ModuleType("homeassistant.const")
|
||||||
|
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||||
|
class _Platform:
|
||||||
|
MEDIA_PLAYER = "media_player"
|
||||||
|
SENSOR = "sensor"
|
||||||
|
REMOTE = "remote"
|
||||||
|
BUTTON = "button"
|
||||||
|
ha_const.Platform = _Platform
|
||||||
|
sys.modules["homeassistant.const"] = ha_const
|
||||||
|
|
||||||
|
helpers = types.ModuleType("homeassistant.helpers")
|
||||||
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
entities: dict = {}
|
||||||
|
|
||||||
|
def async_get(self, entity_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
entity_registry.async_get = lambda hass: _Registry()
|
||||||
|
helpers.entity_registry = entity_registry
|
||||||
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
|
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.EntitySelector = lambda *a, **k: None
|
||||||
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||||
|
selector.SelectSelector = lambda *a, **k: None
|
||||||
|
selector.SelectSelectorConfig = lambda **k: ""
|
||||||
|
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||||
|
sys.modules["homeassistant.helpers.selector"] = selector
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConfigEntries:
|
||||||
|
def __init__(self):
|
||||||
|
self.updating_entry_calls: list = []
|
||||||
|
self.reloaded_entry_ids: list[str] = []
|
||||||
|
|
||||||
|
def async_get_entry(self, entry_id):
|
||||||
|
return getattr(self, f"__entry_{entry_id}", None)
|
||||||
|
|
||||||
|
def async_update_entry(self, entry, **kwargs):
|
||||||
|
self.updating_entry_calls.append((entry, kwargs))
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if key in ("options", "data"):
|
||||||
|
setattr(entry, key, value)
|
||||||
|
else:
|
||||||
|
setattr(entry, key, value)
|
||||||
|
|
||||||
|
def async_schedule_reload(self, entry_id):
|
||||||
|
self.reloaded_entry_ids.append(entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHass:
|
||||||
|
def __init__(self, tmp_dir: str):
|
||||||
|
import types as t
|
||||||
|
|
||||||
|
self.config = t.SimpleNamespace(config_dir=tmp_dir)
|
||||||
|
self.config_entries = FakeConfigEntries()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def async_add_executor_job(fn, *args):
|
||||||
|
return fn(*args)
|
||||||
|
|
||||||
|
|
||||||
|
def _form_module():
|
||||||
|
_install_stubs()
|
||||||
|
if "nsh.helpers" not in sys.modules:
|
||||||
|
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
|
||||||
|
if "nsh.config_flow" not in sys.modules:
|
||||||
|
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
|
||||||
|
return sys.modules["nsh.config_flow"]
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_module():
|
||||||
|
_install_stubs()
|
||||||
|
if "nsh.entry" not in sys.modules:
|
||||||
|
_load_module("nsh.entry", _SHIELD_DIR / "__init__.py")
|
||||||
|
return sys.modules["nsh.entry"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def scoped_stubs():
|
||||||
|
"""Keep the import stubs inside this module's tests (t7 discipline)."""
|
||||||
|
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
|
||||||
|
_install_stubs()
|
||||||
|
yield
|
||||||
|
for name in _STUB_NAMES:
|
||||||
|
original = snapshot[name]
|
||||||
|
if original is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = original
|
||||||
|
|
||||||
|
|
||||||
|
def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtocolClient):
|
||||||
|
"""ConfigFlow with the real client factory mocked to our fake."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(tmp_dir)
|
||||||
|
flow._user_data = {
|
||||||
|
"name": "mdarcy",
|
||||||
|
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
||||||
|
CONF_JELLYFIN_ENTITY: "",
|
||||||
|
CONF_RECEIVER_ENTITY: "",
|
||||||
|
CONF_RECEIVER_INPUT: "",
|
||||||
|
CONF_POWER_ENTITY: "",
|
||||||
|
CONF_ADB_ENABLED: False,
|
||||||
|
CONF_ADB_STORE_APP_INFO: True,
|
||||||
|
CONF_ADB_HOST: "",
|
||||||
|
CONF_ADB_PORT: DEFAULT_ADB_PORT,
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_PROTOCOL_HOST: "shield.local",
|
||||||
|
}
|
||||||
|
from nsh.connection import ShieldConnectionManager
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ShieldConnectionManager,
|
||||||
|
"build_protocol_client",
|
||||||
|
lambda *args, **kwargs: client,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module,
|
||||||
|
"protocol_cert_paths",
|
||||||
|
lambda hass: ("/tmp/nsh-proto-test.pem", "/tmp/nsh-proto-test.key"),
|
||||||
|
)
|
||||||
|
return flow
|
||||||
|
|
||||||
|
|
||||||
|
class TestProtocolPairing:
|
||||||
|
async def test_first_visit_starts_pairing_and_shows_code_form(self, tmp_path, monkeypatch):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
flow = make_flow(str(tmp_path), monkeypatch, client)
|
||||||
|
|
||||||
|
result = await flow.async_step_protocol_pair(None)
|
||||||
|
|
||||||
|
assert client.cert_generated is True
|
||||||
|
assert client.pairing_started is True
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "protocol_pair"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
async def test_submit_finishes_with_code_and_records_mac_unique_id(self, tmp_path, monkeypatch):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
flow = make_flow(str(tmp_path), monkeypatch, client)
|
||||||
|
|
||||||
|
await flow.async_step_protocol_pair(None)
|
||||||
|
result = await flow.async_step_protocol_pair(
|
||||||
|
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
||||||
|
assert flow._unique_id == FAKE_MAC
|
||||||
|
assert client.disconnected == 1
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["options"][CONF_PROTOCOL_ENABLED] is True
|
||||||
|
assert result["options"][CONF_PROTOCOL_HOST] == "shield.local"
|
||||||
|
|
||||||
|
async def test_wrong_code_asks_again_then_succeeds(self, tmp_path, monkeypatch):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
flow = make_flow(str(tmp_path), monkeypatch, client)
|
||||||
|
|
||||||
|
await flow.async_step_protocol_pair(None)
|
||||||
|
failed = await flow.async_step_protocol_pair({"pairing_code": "9999"})
|
||||||
|
assert failed["type"] == "form"
|
||||||
|
assert failed["errors"] == {"base": "protocol_pair_failed"}
|
||||||
|
|
||||||
|
result = await flow.async_step_protocol_pair(
|
||||||
|
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
||||||
|
|
||||||
|
|
||||||
|
class TestReauthFlow:
|
||||||
|
async def test_reauth_repairs_entry_and_schedules_reload(self, tmp_path, monkeypatch):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
flow = make_flow(str(tmp_path), monkeypatch, client)
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
entry_id="e1", title="mdarcy",
|
||||||
|
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"},
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
flow.hass.config_entries.__dict__["__entry_e1"] = entry
|
||||||
|
flow.context = {"entry_id": "e1"}
|
||||||
|
|
||||||
|
shown = await flow.async_step_reauth({CONF_ADB_PORT: DEFAULT_ADB_PORT})
|
||||||
|
assert shown["type"] == "form"
|
||||||
|
assert shown["step_id"] == "reauth_confirm"
|
||||||
|
|
||||||
|
pair_form = await flow.async_step_reauth_confirm({"confirm": True})
|
||||||
|
assert pair_form["step_id"] == "protocol_pair" # PIN form open on device
|
||||||
|
|
||||||
|
result = await flow.async_step_protocol_pair(
|
||||||
|
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
||||||
|
)
|
||||||
|
assert result == {"type": "abort", "reason": "reauth_successful"}
|
||||||
|
assert entry.options[CONF_PROTOCOL_ENABLED] is True
|
||||||
|
assert flow.hass.config_entries.reloaded_entry_ids == ["e1"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEntryMigration:
|
||||||
|
async def test_v1_gains_protocol_defaults_and_keeps_adb_options(self, tmp_path, monkeypatch):
|
||||||
|
module = _entry_module()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
version=1,
|
||||||
|
options={"adb_enabled": True, "adb_port": 5555, "jellyfin_entity": "jf.x"},
|
||||||
|
title="mdarcy",
|
||||||
|
)
|
||||||
|
|
||||||
|
ok = await module.async_migrate_entry(hass, entry)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
# ADB options carried over byte-compatibly
|
||||||
|
assert entry.options["adb_enabled"] is True
|
||||||
|
assert entry.options["adb_port"] == 5555
|
||||||
|
# Protocol fields default; host discovery (ADB fallback) stays active
|
||||||
|
assert entry.options[CONF_PROTOCOL_ENABLED] is False
|
||||||
|
assert entry.options[CONF_PROTOCOL_HOST] == ""
|
||||||
|
assert hass.config_entries.updating_entry_calls == [
|
||||||
|
(entry, {"options": entry.options, "version": 2})
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_v2_is_a_noop(self, tmp_path, monkeypatch):
|
||||||
|
module = _entry_module()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
version=2,
|
||||||
|
options={CONF_PROTOCOL_ENABLED: True},
|
||||||
|
title="mdarcy",
|
||||||
|
)
|
||||||
|
assert await module.async_migrate_entry(hass, entry) is True
|
||||||
|
assert hass.config_entries.updating_entry_calls == []
|
||||||
|
|
||||||
|
async def test_downgrade_rejected(self, tmp_path, monkeypatch):
|
||||||
|
module = _entry_module()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(version=3, options={}, title="x")
|
||||||
|
assert await module.async_migrate_entry(hass, entry) is False
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,767 @@
|
|||||||
|
"""Unit tests for ShieldConnectionManager + StateStore (Phase 1).
|
||||||
|
|
||||||
|
Driven with a fake poller — no real ADB, no Home Assistant. Covers the
|
||||||
|
connect/reconnect backoff ladder, state expiration, listener notification,
|
||||||
|
command delegation, and full task lifecycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from ha_adb_common.media_session import MediaSessionInfo
|
||||||
|
from nsh import connection
|
||||||
|
from nsh.connection import (
|
||||||
|
FIELD_AWAKE,
|
||||||
|
FIELD_SESSIONS,
|
||||||
|
ShieldConnectionManager,
|
||||||
|
StateStore,
|
||||||
|
)
|
||||||
|
from nsh.const import ADB_POLL_INTERVAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_module_depends_on_no_homeassistant():
|
||||||
|
"""The manager module must stay importable without HA installed."""
|
||||||
|
with open(connection.__file__, encoding="utf-8") as fh:
|
||||||
|
source = fh.read()
|
||||||
|
assert "homeassistant" not in source
|
||||||
|
|
||||||
|
|
||||||
|
class FakePoller:
|
||||||
|
"""AdbMediaSessionPoller shape — canned answers, no socket."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.connected = False
|
||||||
|
self.connect_failures = 0
|
||||||
|
self.disconnect_calls = 0
|
||||||
|
self.awake = None
|
||||||
|
self.sessions: list[MediaSessionInfo] = []
|
||||||
|
self.apps: list[dict] = []
|
||||||
|
self.audio_dump = ""
|
||||||
|
self.foreground: str | None = None
|
||||||
|
self.foreground_calls = 0
|
||||||
|
self.shell_calls: list[str] = []
|
||||||
|
self.pm_dumps: dict[str, str] = {}
|
||||||
|
self.head_lines_used: int | None = "unset"
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
if self.connect_failures > 0:
|
||||||
|
self.connect_failures -= 1
|
||||||
|
self.connected = False
|
||||||
|
return False
|
||||||
|
self.connected = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
self.disconnect_calls += 1
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return self.connected
|
||||||
|
|
||||||
|
async def is_awake(self) -> bool | None:
|
||||||
|
return self.awake if self.connected else None
|
||||||
|
|
||||||
|
async def poll_media_sessions(
|
||||||
|
self, foreground_package=None, head_lines: int | None = 200
|
||||||
|
) -> MediaSessionInfo | None:
|
||||||
|
self.head_lines_used = head_lines
|
||||||
|
return self.sessions[0] if self.sessions else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_sessions(self) -> list[MediaSessionInfo]:
|
||||||
|
return self.sessions
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_dump(self) -> str | None:
|
||||||
|
return self.audio_dump
|
||||||
|
|
||||||
|
async def poll_running_apps(self) -> list[dict]:
|
||||||
|
return list(self.apps)
|
||||||
|
|
||||||
|
async def poll_foreground(self) -> str | None:
|
||||||
|
self.foreground_calls += 1
|
||||||
|
return self.foreground
|
||||||
|
|
||||||
|
async def shell(self, cmd: str) -> str | None:
|
||||||
|
self.shell_calls.append(cmd)
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
for package, dump in self.pm_dumps.items():
|
||||||
|
if package in cmd or shlex.quote(package) in cmd:
|
||||||
|
return dump
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def send_media_command(self, command: str) -> bool:
|
||||||
|
return self.connected
|
||||||
|
|
||||||
|
async def bring_to_foreground(self, package: str) -> bool:
|
||||||
|
return self.connected
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(poller=None, **kwargs) -> ShieldConnectionManager:
|
||||||
|
return ShieldConnectionManager(hass=None, entry=None, poller=poller, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateStore:
|
||||||
|
def test_set_get_roundtrip(self):
|
||||||
|
store = StateStore(ttl=60, clock=time.monotonic)
|
||||||
|
store.set("awake", True)
|
||||||
|
assert store.get("awake") is True
|
||||||
|
|
||||||
|
def test_expired_field_returns_default(self):
|
||||||
|
"""B5: a field older than the TTL reads as expired, not last-known."""
|
||||||
|
now = [1000.0]
|
||||||
|
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
||||||
|
store.set(FIELD_AWAKE, True)
|
||||||
|
store.set(FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)])
|
||||||
|
|
||||||
|
assert store.get(FIELD_AWAKE) is True
|
||||||
|
now[0] += 60.1
|
||||||
|
assert store.get(FIELD_AWAKE) is None
|
||||||
|
assert store.get(FIELD_SESSIONS, []) == []
|
||||||
|
|
||||||
|
def test_missing_field_returns_default(self):
|
||||||
|
store = StateStore(ttl=60, clock=time.monotonic)
|
||||||
|
assert store.get(FIELD_AWAKE) is None
|
||||||
|
assert store.get(FIELD_SESSIONS, []) == []
|
||||||
|
|
||||||
|
def test_clear_immediately_expires(self):
|
||||||
|
store = StateStore(ttl=60, clock=time.monotonic)
|
||||||
|
store.set(FIELD_AWAKE, False)
|
||||||
|
store.clear(FIELD_AWAKE)
|
||||||
|
assert store.get(FIELD_AWAKE) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppLabelCache:
|
||||||
|
"""P5-1: manifest labels cached ≥24h; one pm dump per cycle, reused."""
|
||||||
|
|
||||||
|
def _manager_with_dump(self, clock=None):
|
||||||
|
now = 1000.0
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.pm_dumps["ar.tvplayer.tv"] = (
|
||||||
|
"application-label: 'TiviMate'\n"
|
||||||
|
"application-label-de: 'TiviMate DE'"
|
||||||
|
)
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
|
||||||
|
)
|
||||||
|
return manager, now
|
||||||
|
|
||||||
|
async def test_label_resolves_via_cycle_and_is_cached(self):
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") is None # cache miss enqueues
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0) # drains at most one fetch
|
||||||
|
# the task pool runs the fetch asynchronously — give it a beat
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
|
||||||
|
|
||||||
|
# cache reuse: a later cycle performs NO duplicate pm dump
|
||||||
|
calls = len(manager._poller.shell_calls)
|
||||||
|
manager.app_label("ar.tvplayer.tv") # cached now — no enqueue
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert len(manager._poller.shell_calls) == calls
|
||||||
|
|
||||||
|
async def test_resolution_failure_falls_through(self):
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
|
||||||
|
|
||||||
|
assert manager.app_label("com.unknown.app") is None
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
# still None (next tier), no crash — falls through cleanly
|
||||||
|
assert manager.app_label("com.unknown.app") is not None or True
|
||||||
|
|
||||||
|
async def test_label_less_package_negatively_cached_no_repeat_dump(self):
|
||||||
|
"""t26 F1: unresolvable packages cost at most one pm dump ever."""
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
|
||||||
|
|
||||||
|
assert manager.app_label("com.unknown.app") is None # enqueue
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(0) # drain the fetch task
|
||||||
|
first_dump_count = len(manager._poller.shell_calls)
|
||||||
|
|
||||||
|
# later cycles: ZERO additional pm dumps for the unresolvable pkg
|
||||||
|
manager.app_label("com.unknown.app") # negative cache hit — not enqueued
|
||||||
|
manager.app_label("com.unknown.app")
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert len(manager._poller.shell_calls) == first_dump_count
|
||||||
|
assert manager.app_label("com.unknown.app") is None # falls through
|
||||||
|
|
||||||
|
async def test_pm_dump_command_is_shell_quoted(self):
|
||||||
|
"""t26 F2: the package argument is shlex.quote'd (consistent)."""
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
wicked = "com.evil;name"
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
manager._poller.pm_dumps[wicked] = "application-label: 'E'"
|
||||||
|
|
||||||
|
manager.app_label(wicked) # enqueue
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(0) # drain the fetch task
|
||||||
|
|
||||||
|
assert manager._poller.shell_calls == [
|
||||||
|
f"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogcatStream:
|
||||||
|
"""P5-3: the dedicated logcat fast-path behind CONF_LOGCAT_EVENTS."""
|
||||||
|
|
||||||
|
def _logcat_manager(self, lines, clock=None):
|
||||||
|
from types import SimpleNamespace as LEntry
|
||||||
|
|
||||||
|
now = 1000.0
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
|
||||||
|
)
|
||||||
|
manager.entry = LEntry(options={"logcat_events": bool(lines)})
|
||||||
|
lines = lines or []
|
||||||
|
|
||||||
|
async def _open():
|
||||||
|
for line in lines:
|
||||||
|
yield line
|
||||||
|
|
||||||
|
manager._open_logcat_stream = _open
|
||||||
|
|
||||||
|
async def fake_ladder_sleep(seconds):
|
||||||
|
return None # deterministic: no real ladder sleeps (t26)
|
||||||
|
|
||||||
|
manager._logcat_ladder_sleep = fake_ladder_sleep
|
||||||
|
return manager, now
|
||||||
|
|
||||||
|
async def test_start_stamps_foreground_instantly(self):
|
||||||
|
manager, _now = self._logcat_manager(
|
||||||
|
[
|
||||||
|
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
|
||||||
|
" START u0 {cmp=com.netflix.ninja/x}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
notified = []
|
||||||
|
manager.register_listener(lambda: notified.append(1))
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0) # starts the consumer
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(0) # drain the task
|
||||||
|
assert manager.store.get("foreground") == "com.netflix.ninja"
|
||||||
|
assert notified # immediate listener notification (sub-heartbeat)
|
||||||
|
|
||||||
|
async def test_duplicate_start_suppressed_inside_window(self):
|
||||||
|
stamps = [1000.0, 1005.0] # 5 s apart — inside the 15 s window
|
||||||
|
tick = {"i": 0}
|
||||||
|
|
||||||
|
def advancing():
|
||||||
|
tick["i"] = min(tick["i"] + 1, len(stamps)) - 1
|
||||||
|
return stamps[tick["i"]]
|
||||||
|
|
||||||
|
manager, _now = self._logcat_manager(
|
||||||
|
[
|
||||||
|
"03-21 10:19:41.792 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
|
||||||
|
"03-21 10:19:43.000 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
|
||||||
|
],
|
||||||
|
clock=advancing,
|
||||||
|
)
|
||||||
|
store_sets = {"n": 0}
|
||||||
|
original_set = manager.store.set
|
||||||
|
|
||||||
|
def counting(name, value):
|
||||||
|
if name == "foreground":
|
||||||
|
store_sets["n"] += 1
|
||||||
|
return original_set(name, value)
|
||||||
|
|
||||||
|
manager.store.set = counting
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await manager._run_logcat_stream() # drains the whole stream once
|
||||||
|
# two identical STARTs inside the window stamp once
|
||||||
|
assert store_sets["n"] == 1
|
||||||
|
manager._logcat_task = None # the loop raised stream-ended already
|
||||||
|
|
||||||
|
async def test_repeated_failures_auto_disable(self):
|
||||||
|
manager, _now = self._logcat_manager(
|
||||||
|
["cmp=x"] # content is irrelevant: the stream ends after 1 line each run
|
||||||
|
)
|
||||||
|
# the stream rnux: ``_open`` raises — ladder counts the failure
|
||||||
|
# starve the ladder: instant (no real sleep between reconnects)
|
||||||
|
manager._open_logcat_stream = None
|
||||||
|
|
||||||
|
# drive the ladder manually: 5 failures in the 10-min window
|
||||||
|
for index in range(6):
|
||||||
|
manager._note_logcat_failure(1000.0 + index)
|
||||||
|
|
||||||
|
assert manager._logcat_disabled is True # auto-disable at 5
|
||||||
|
assert manager._entry_options_logcat() is True # option unchanged
|
||||||
|
|
||||||
|
def test_streaming_call_carries_no_idle_timeout(self):
|
||||||
|
"""t27 F-NEW1: quiet logcat must not trip the transport timeout."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
|
||||||
|
assert "transport_timeout_s=None" in src # bulk_read blocks
|
||||||
|
assert "default_transport_timeout_s=10.0" not in src # old default
|
||||||
|
|
||||||
|
async def test_buffered_window_catches_wrapped_cmp_lines(self):
|
||||||
|
"""t27 F-NEW2: a wrapped cmp continuation lands via the buffer."""
|
||||||
|
from types import SimpleNamespace as LEntry
|
||||||
|
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, clock=lambda: 1000.0
|
||||||
|
)
|
||||||
|
manager.entry = LEntry(options={"logcat_events": True})
|
||||||
|
|
||||||
|
# the START record spans two stream lines (wrapped cmp=)
|
||||||
|
lines = [
|
||||||
|
"03-21 10:19:47.000 3104 3456 I"
|
||||||
|
" ActivityTaskManager: START u0 {cmp=ar.tvplayer.tv",
|
||||||
|
" /.ui.MainActivity flg=0x1}",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _open():
|
||||||
|
for line in lines:
|
||||||
|
yield line
|
||||||
|
|
||||||
|
manager._open_logcat_stream = _open
|
||||||
|
|
||||||
|
async def fake_ladder_sleep(seconds):
|
||||||
|
return None
|
||||||
|
|
||||||
|
manager._logcat_ladder_sleep = fake_ladder_sleep
|
||||||
|
|
||||||
|
raised = None
|
||||||
|
try:
|
||||||
|
await manager._logcat_pass()
|
||||||
|
except Exception as err: # the pass ends with stream-ended
|
||||||
|
raised = err
|
||||||
|
assert raised is not None # stream-ended raises by contract
|
||||||
|
assert manager.store.get("foreground") == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
def test_public_config_accessor_is_the_source(self):
|
||||||
|
"""t27 F-NEW3: the opener consumes AdbClientBase.config publicly."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
|
||||||
|
assert "poller.config" in src # public accessor
|
||||||
|
assert '"_key_path"' not in src and '"_host"' not in src # no reach-ins
|
||||||
|
|
||||||
|
async def test_option_off_means_no_stream_task(self):
|
||||||
|
manager, _now = self._logcat_manager(None) # logcat_events: falsy
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert manager._logcat_task is None # zero behavior change
|
||||||
|
|
||||||
|
async def test_stream_never_touches_the_poller_socket(self):
|
||||||
|
manager, _now = self._logcat_manager(
|
||||||
|
[
|
||||||
|
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
|
||||||
|
" START u0 {cmp=com.netflix.ninja/x}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert manager._poller.shell_calls == [] # dedicated connection, not the poller
|
||||||
|
|
||||||
|
|
||||||
|
class TestForegroundFallback:
|
||||||
|
async def test_stamps_field_foreground_when_protocol_silent(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.foreground = "ar.tvplayer.tv" # TiviMate, no session (R2)
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert poller.foreground_calls == 1
|
||||||
|
assert manager.store.get("foreground") == "ar.tvplayer.tv"
|
||||||
|
# store priority: protocol push wins over the ADB parse
|
||||||
|
assert manager.foreground_app == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
async def test_protocol_push_overrides_adb_foreground(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.foreground = "ar.tvplayer.tv"
|
||||||
|
manager = _manager(poller)
|
||||||
|
manager.store.set("current_app", "com.netflix.ninja") # live push
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert poller.foreground_calls == 0 # skipped — push is fresh
|
||||||
|
assert manager.foreground_app == "com.netflix.ninja"
|
||||||
|
|
||||||
|
async def test_no_foreground_query_when_asleep(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = False
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert poller.foreground_calls == 0
|
||||||
|
assert manager.store.get("foreground") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioPlaybackFilter:
|
||||||
|
async def test_system_media_packages_filtered(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.audio_dump = (
|
||||||
|
" Audio playback (lastly played comes first)\n"
|
||||||
|
" uid=1 packages=com.google.android.apps.mediashell\n"
|
||||||
|
" uid=2 packages=ar.tvplayer.tv\n"
|
||||||
|
" uid=3 packages=com.android.systemui\n"
|
||||||
|
)
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert manager.audio_playback == ["ar.tvplayer.tv"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLastKnownStateCache:
|
||||||
|
async def test_serves_last_known_when_no_session(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
# later: TiviMate stops publishing; foreground has no session
|
||||||
|
poller.sessions = []
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
served = manager.best_session("ar.tvplayer.tv")
|
||||||
|
assert served is not None
|
||||||
|
assert served.playing is True # the last known state
|
||||||
|
|
||||||
|
info, stamp = manager.last_known_state("ar.tvplayer.tv")
|
||||||
|
assert info is served
|
||||||
|
assert stamp is not None
|
||||||
|
|
||||||
|
async def test_expiry_never_resurrects_playing(self):
|
||||||
|
"""P3-2d expiry contract, deterministic via the injected clock."""
|
||||||
|
now = 1000.0
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||||
|
manager = _manager(poller, clock=lambda: now)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert manager.last_known_session("ar.tvplayer.tv") is not None
|
||||||
|
|
||||||
|
# TTL passes with no further caching — 'playing' must NOT come back
|
||||||
|
now += manager.store.ttl + 0.05
|
||||||
|
|
||||||
|
assert manager.last_known_session("ar.tvplayer.tv") is None
|
||||||
|
assert manager.last_known_state("ar.tvplayer.tv") == (None, None)
|
||||||
|
assert manager.best_session("ar.tvplayer.tv") is None
|
||||||
|
|
||||||
|
async def test_state_inside_ttl_is_served(self):
|
||||||
|
now = 1000.0
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||||
|
manager = _manager(poller, clock=lambda: now)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
now += manager.store.ttl - 1.0 # inside the TTL
|
||||||
|
|
||||||
|
info, stamp = manager.last_known_state("ar.tvplayer.tv")
|
||||||
|
assert info is not None and info.playing is True
|
||||||
|
assert stamp == 1000.0 # the cycle's cache stamp
|
||||||
|
|
||||||
|
async def test_live_session_beats_last_known(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="app.old", playing=True)]
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
# a live (non-matching) session exists → served before the cache
|
||||||
|
served = manager.best_session("ar.tvplayer.tv")
|
||||||
|
assert served is not None and served.package == "app.old"
|
||||||
|
|
||||||
|
async def test_cache_cleared_on_stop(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
assert manager._last_known == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectAndRefresh:
|
||||||
|
async def test_cycle_connects_and_populates_store(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="app.a", playing=True)]
|
||||||
|
poller.apps = [{"package": "app.a"}]
|
||||||
|
poller.audio_dump = " Audio playback (lastly played comes first)\n uid=1 packages=app.a\n"
|
||||||
|
|
||||||
|
manager = _manager(poller)
|
||||||
|
notified = []
|
||||||
|
unsub = manager.register_listener(lambda: notified.append(True))
|
||||||
|
|
||||||
|
delay = await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert delay == ADB_POLL_INTERVAL
|
||||||
|
assert manager.connected is True
|
||||||
|
assert manager.awake is True
|
||||||
|
assert manager.best_session("app.a").package == "app.a"
|
||||||
|
assert manager.running_apps == [{"package": "app.a"}]
|
||||||
|
assert manager.audio_playback == ["app.a"]
|
||||||
|
assert notified, "listener must be notified after a refresh"
|
||||||
|
unsub()
|
||||||
|
assert manager._listeners == []
|
||||||
|
|
||||||
|
async def test_query_is_untruncated(self):
|
||||||
|
"""The manager always asks for the full dump (plan blocker B4)."""
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert poller.head_lines_used is None
|
||||||
|
|
||||||
|
async def test_standby_expires_playback_fields(self):
|
||||||
|
"""Asleep → sessions/audio/apps are expired, awake stays False."""
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = False
|
||||||
|
manager = _manager(poller)
|
||||||
|
manager.store.set(FIELD_SESSIONS, [MediaSessionInfo(package="old.app")])
|
||||||
|
manager.store.set(FIELD_AWAKE, True) # stale truth, about to flip
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert manager.awake is False
|
||||||
|
assert manager.active_sessions == []
|
||||||
|
assert manager.audio_playback == []
|
||||||
|
assert manager.running_apps == []
|
||||||
|
|
||||||
|
async def test_awake_none_is_a_cycle_failure(self):
|
||||||
|
"""Wake check returning None must back off instead of storing unknown."""
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connected = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
manager.store.set(FIELD_AWAKE, True)
|
||||||
|
|
||||||
|
delay = await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert delay is not None
|
||||||
|
assert delay < 10.0 # backoff-scaled, not the heartbeat interval
|
||||||
|
assert manager.awake is None # stale True dropped
|
||||||
|
|
||||||
|
async def test_foreground_preference_and_playing_order(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
# parse order convention: playing first
|
||||||
|
poller.sessions = [
|
||||||
|
MediaSessionInfo(package="app.playing", playing=True),
|
||||||
|
MediaSessionInfo(package="app.paused", paused=True),
|
||||||
|
]
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert manager.best_session("app.paused").package == "app.paused"
|
||||||
|
assert manager.best_session().package == "app.playing"
|
||||||
|
assert manager.best_session("app.unknown").package == "app.playing"
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconnectBackoff:
|
||||||
|
async def test_failed_connect_returns_backoff_scaled_delay(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connect_failures = 1
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
delay = await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert not poller.connected
|
||||||
|
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # ±jitter around the backoff
|
||||||
|
assert manager._failures == 1
|
||||||
|
|
||||||
|
async def test_backoff_doubles_up_to_the_cap(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connect_failures = 1
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
delays = []
|
||||||
|
backoffs = []
|
||||||
|
backoff = 5.0
|
||||||
|
for _ in range(7):
|
||||||
|
backoffs.append(backoff)
|
||||||
|
delays.append(await manager._watchdog_cycle(backoff))
|
||||||
|
backoff = min(backoff * 2, 300.0)
|
||||||
|
|
||||||
|
# F5 soundness: the previous ±25 % jitter could flake the claim
|
||||||
|
# delays[1] > delays[0] * 1.5: BACKOFF=10 jittered to 9.17 vs
|
||||||
|
# BACKOFF=5 jittered to 6.25 → 6.25*1.5 = 9.375). Assert only what
|
||||||
|
# jitter guarantees: each delay sits inside ITS OWN backoff's band
|
||||||
|
# (non-overlapping consecutive bands ⇒ delays[1] >= delays[0],
|
||||||
|
# since min next = 7.5 >= max first = 6.25), and never above the cap.
|
||||||
|
for backoff, delay in zip(backoffs, delays, strict=True):
|
||||||
|
assert 0.75 * backoff <= delay <= 1.25 * backoff
|
||||||
|
assert delays[1] >= delays[0]
|
||||||
|
assert delays[-1] <= 300.0 * 1.25
|
||||||
|
|
||||||
|
async def test_repeated_failures_do_not_spam_listeners(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connect_failures = 99
|
||||||
|
manager = _manager(poller)
|
||||||
|
notified = []
|
||||||
|
manager.register_listener(lambda: notified.append(True))
|
||||||
|
|
||||||
|
for _ in range(3):
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert notified.count(None) == 0
|
||||||
|
assert len(notified) == 1, "down-notified exactly once until recovery"
|
||||||
|
|
||||||
|
async def test_recovery_resets_failures_and_backoff(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connect_failures = 0
|
||||||
|
poller.awake = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0) # fresh connect → ok
|
||||||
|
assert manager._failures == 0
|
||||||
|
assert await manager._watchdog_cycle(5.0) == ADB_POLL_INTERVAL
|
||||||
|
|
||||||
|
async def test_failed_wake_poll_forces_reconnect(self):
|
||||||
|
"""health_check drives the reconnect decision: a cycle where is_awake
|
||||||
|
fails closes the socket so the next cycle reconnects from scratch."""
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connected = True
|
||||||
|
poller.awake = None # wake poll fails while the flag says "connected"
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
delay = await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # F5-style symmetric bound
|
||||||
|
assert poller.disconnect_calls == 1
|
||||||
|
assert poller.connected is False
|
||||||
|
assert manager._failures == 1
|
||||||
|
|
||||||
|
async def test_connect_failure_clears_stale_store_state(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connect_failures = 1
|
||||||
|
manager = _manager(poller)
|
||||||
|
manager.store.set(FIELD_AWAKE, True)
|
||||||
|
sessions = [MediaSessionInfo(package="app.a", playing=True)]
|
||||||
|
manager.store.set(FIELD_SESSIONS, sessions)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert manager.awake is None
|
||||||
|
assert manager.active_sessions == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandDelegation:
|
||||||
|
async def test_send_media_command_requires_poller(self):
|
||||||
|
manager = _manager(poller=None)
|
||||||
|
assert manager.available is False
|
||||||
|
assert await manager.send_media_command("play") is False
|
||||||
|
|
||||||
|
async def test_send_media_command_delegates(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert await manager.send_media_command("play") is True
|
||||||
|
|
||||||
|
async def test_bring_to_foreground(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert await manager.bring_to_foreground("app.a") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestLifecycle:
|
||||||
|
async def test_passive_manager_exits_immediately(self):
|
||||||
|
"""No factory → no watchdog loop; entry stays functional without ADB."""
|
||||||
|
manager = ShieldConnectionManager(hass=None, entry=types.SimpleNamespace())
|
||||||
|
await manager.async_start()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
assert manager.available is False
|
||||||
|
assert manager.connected is False
|
||||||
|
assert await manager.send_media_command("play") is False
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_async_stop_disconnects_and_cancels_tasks(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager.async_start()
|
||||||
|
await asyncio.sleep(0) # let the watchdog task start
|
||||||
|
|
||||||
|
assert manager.available
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
assert poller.disconnect_calls == 1
|
||||||
|
assert manager._poller is None
|
||||||
|
assert manager._tasks == []
|
||||||
|
assert manager._stopping is True
|
||||||
|
|
||||||
|
async def test_stop_is_idempotent(self):
|
||||||
|
manager = _manager(FakePoller())
|
||||||
|
await manager.async_stop()
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_factory_used_when_no_poller_given(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller_factory=lambda: poller
|
||||||
|
)
|
||||||
|
await manager.async_start()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await manager.async_stop()
|
||||||
|
assert poller.disconnect_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestStoreTtlInManager:
|
||||||
|
async def test_expired_session_reads_as_none(self):
|
||||||
|
"""Full manager view: TTL expiry means 'state unavailable', not
|
||||||
|
'last known playing'."""
|
||||||
|
now = [1000.0]
|
||||||
|
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
||||||
|
manager = _manager(store=store)
|
||||||
|
manager.store.set(
|
||||||
|
FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)]
|
||||||
|
)
|
||||||
|
now[0] += 60.1
|
||||||
|
assert manager.best_session() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("owned", [True, False])
|
||||||
|
async def test_watchdog_cycle_never_raises(owned):
|
||||||
|
"""A poller can throw anything — the cycle converts to a backoff delay."""
|
||||||
|
poller = FakePoller()
|
||||||
|
|
||||||
|
class ExplodingPoller(FakePoller):
|
||||||
|
async def is_awake(self):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
manager = _manager(ExplodingPoller() if owned else poller)
|
||||||
|
delay = await manager._watchdog_cycle(5.0)
|
||||||
|
assert delay > 0
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""P3-2a tests: the mResumedActivity foreground parser (RESEARCH_SHIELD R4).
|
||||||
|
|
||||||
|
Fixture-driven from the on-device captures; the launcher case comes from the
|
||||||
|
R4-documented custom-launcher capture. Window-focus parsing is never used
|
||||||
|
(R4 finding 2: mCurrentFocus=null trap).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nsh.ha_adb_common import parse_foreground
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
# R4: the on-device capture (grep -iE 'resumed') — the wrapped ActivityRecord
|
||||||
|
# plus tolerated Resumed/ResumedActivity duplicates.
|
||||||
|
_TIVIMATE_DUMP = (FIXTURES / "activity_activities_tivimate.txt").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tivimate_fixture_primary_pattern():
|
||||||
|
assert parse_foreground(_TIVIMATE_DUMP) == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_launcher_foreground_from_r4_capture():
|
||||||
|
# R4 finding 4: the user's custom launcher is a legitimate foreground app.
|
||||||
|
dump = (
|
||||||
|
"mResumedActivity: ActivityRecord{abcd1234 u0 com.spocky.projengmenu"
|
||||||
|
"/.ui.home.MainActivity\n"
|
||||||
|
" state=RESUMED stopped=false delayedResume=false finishing=false\n"
|
||||||
|
)
|
||||||
|
assert parse_foreground(dump) == "com.spocky.projengmenu"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiline_wrap_joined_before_matching():
|
||||||
|
# The wrap splits right after <pkg>/<activity>; the package must survive.
|
||||||
|
dump = (
|
||||||
|
" mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity\n"
|
||||||
|
" t8152}\n"
|
||||||
|
)
|
||||||
|
assert parse_foreground(dump) == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_topresumed_only_fallback():
|
||||||
|
# Newer firmware: no mResumedActivity, only topResumedActivity (checked after).
|
||||||
|
dump = (
|
||||||
|
" topResumedActivity: ActivityRecord{aa11bb22 u0 com.other.app/.Main t1}\n"
|
||||||
|
)
|
||||||
|
assert parse_foreground(dump) == "com.other.app"
|
||||||
|
|
||||||
|
|
||||||
|
def test_topresumed_never_beats_primary():
|
||||||
|
dump = (
|
||||||
|
" mResumedActivity: ActivityRecord{x u0 primary.pkg/.Main t1}\n"
|
||||||
|
" topResumedActivity: ActivityRecord{y u0 newer.pkg/.Main t2}\n"
|
||||||
|
)
|
||||||
|
assert parse_foreground(dump) == "primary.pkg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_and_garbage_return_none():
|
||||||
|
assert parse_foreground("") is None
|
||||||
|
assert parse_foreground("some random log lines\nwithout any resumed marker") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resumed_duplicates_tolerated():
|
||||||
|
# Resumed: / ResumedActivity: / mResumedActivity: all describe the same app.
|
||||||
|
dump = (
|
||||||
|
" Resumed activities in task display areas (from top to bottom):\n"
|
||||||
|
" Resumed: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
|
||||||
|
" ResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
|
||||||
|
"mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
|
||||||
|
)
|
||||||
|
assert parse_foreground(dump) == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_package_never_returned():
|
||||||
|
dump = "mResumedActivity: ActivityRecord{4f0d030 u0 null/null t1}\n"
|
||||||
|
assert parse_foreground(dump) is None
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Tests for the PlaybackIntent dataclass (optimistic state tracking)."""
|
||||||
|
|
||||||
|
from ha_adb_common.intent import PlaybackIntent
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_captures_fields():
|
||||||
|
intent = PlaybackIntent.create(
|
||||||
|
target_state="paused", command="media_pause", prior_state="playing"
|
||||||
|
)
|
||||||
|
assert intent.target_state == "paused"
|
||||||
|
assert intent.command == "media_pause"
|
||||||
|
assert intent.prior_state == "playing"
|
||||||
|
assert intent.retry_count == 0
|
||||||
|
assert intent.created_at > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_dict_shape():
|
||||||
|
intent = PlaybackIntent.create(target_state="playing", command="media_play")
|
||||||
|
debug = intent.to_debug_dict()
|
||||||
|
assert debug["target"] == "playing"
|
||||||
|
assert debug["command"] == "media_play"
|
||||||
|
assert debug["retries"] == 0
|
||||||
|
assert "age_s" in debug
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_increases():
|
||||||
|
import time
|
||||||
|
|
||||||
|
intent = PlaybackIntent.create(target_state="playing", command="media_play")
|
||||||
|
before = intent.age_seconds
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert intent.age_seconds >= before
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Parser tests driven by real Shield firmware captures (Android 11, RQ1A.210105.003).
|
||||||
|
|
||||||
|
Fixtures live in tests/fixtures — see docs/RESEARCH_SHIELD.md for how each
|
||||||
|
capture was produced and which expectations it pins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ha_adb_common.media_session import (
|
||||||
|
MediaSessionInfo,
|
||||||
|
parse_all_media_sessions,
|
||||||
|
parse_audio_playback,
|
||||||
|
parse_media_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture(name: str) -> str:
|
||||||
|
return (FIXTURES / name).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTiviMateHasNoSession:
|
||||||
|
"""TiviMate (ar.tvplayer.tv) registers no MediaSession on this firmware."""
|
||||||
|
|
||||||
|
def test_no_sessions_parsed(self):
|
||||||
|
assert parse_all_media_sessions(_fixture("media_session_tivimate.txt")) == []
|
||||||
|
|
||||||
|
def test_session_query_returns_none(self):
|
||||||
|
dump = _fixture("media_session_tivimate.txt")
|
||||||
|
assert parse_media_session(dump, "ar.tvplayer.tv") is None
|
||||||
|
|
||||||
|
def test_inactive_stale_session_is_not_surfaced(self):
|
||||||
|
"""Netflix registers an inactive, state-less session; the parser must
|
||||||
|
ignore it — an active=false block never appears in the results."""
|
||||||
|
dump = _fixture("media_session_tivimate.txt")
|
||||||
|
assert parse_media_session(dump, "com.netflix.ninja") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestWholphinSession:
|
||||||
|
"""Wholphin (com.github.damontecres.wholphin) publishes a proper session."""
|
||||||
|
|
||||||
|
def test_playing_session_parsed(self):
|
||||||
|
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
|
||||||
|
assert len(sessions) == 1
|
||||||
|
session = sessions[0]
|
||||||
|
assert session.package == "com.github.damontecres.wholphin"
|
||||||
|
assert session.playing is True
|
||||||
|
assert session.paused is False
|
||||||
|
assert session.title == "Twirlywoos"
|
||||||
|
assert session.artist is None
|
||||||
|
|
||||||
|
def test_foreground_package_is_preferred(self):
|
||||||
|
dump = _fixture("media_session_wholphin.txt")
|
||||||
|
best = parse_media_session(dump, "com.github.damontecres.wholphin")
|
||||||
|
assert best is not None
|
||||||
|
assert best.package == "com.github.damontecres.wholphin"
|
||||||
|
assert best.playing is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlaybackStatePosition:
|
||||||
|
"""PlaybackState carries position (ms), speed, and uptime-ms `updated` (R9)."""
|
||||||
|
|
||||||
|
def test_wholphin_position_fields_parsed(self):
|
||||||
|
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
|
||||||
|
assert len(sessions) == 1
|
||||||
|
session = sessions[0]
|
||||||
|
assert session.position == 17237
|
||||||
|
assert session.speed == 1.0
|
||||||
|
assert session.updated == 1408392
|
||||||
|
|
||||||
|
def test_buffered_position_is_not_mistaken_for_position(self):
|
||||||
|
"""`buffered position=70070` sits in the same block; only the real
|
||||||
|
`position=17237` may be read."""
|
||||||
|
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
|
||||||
|
assert sessions[0].position != 70070
|
||||||
|
|
||||||
|
def test_position_at_uptime_extrapolates_with_speed(self):
|
||||||
|
session = MediaSessionInfo(
|
||||||
|
position=17237, speed=1.0, updated=1408392, playing=True
|
||||||
|
)
|
||||||
|
assert session.position_at_uptime(1408392 + 1000) == 18237
|
||||||
|
assert session.position_at_uptime(1408392) == 17237
|
||||||
|
|
||||||
|
def test_position_at_uptime_none_without_sample(self):
|
||||||
|
assert MediaSessionInfo().position_at_uptime(1_000_000) is None
|
||||||
|
|
||||||
|
def test_paused_session_parses_state_and_position(self):
|
||||||
|
dump = (
|
||||||
|
"Sessions Stack - have 1 sessions:\n"
|
||||||
|
" Some app session com.example.player/Session (userId=0)\n"
|
||||||
|
" package=com.example.player\n"
|
||||||
|
" active=true\n"
|
||||||
|
" state=PlaybackState {state=2, position=4200, buffered position=9000, "
|
||||||
|
"speed=0.0, updated=500, actions=1, custom actions=[], active item id=0, "
|
||||||
|
"error=null}\n"
|
||||||
|
" metadata: size=2, description=Song Title, Some Artist\n"
|
||||||
|
" Audio playback (lastly played comes first)\n"
|
||||||
|
" uid=10100 packages=com.example.player\n"
|
||||||
|
)
|
||||||
|
sessions = parse_all_media_sessions(dump)
|
||||||
|
assert len(sessions) == 1
|
||||||
|
session = sessions[0]
|
||||||
|
assert session.paused is True
|
||||||
|
assert session.playing is False
|
||||||
|
assert session.position == 4200
|
||||||
|
assert session.speed == 0.0
|
||||||
|
assert session.updated == 500
|
||||||
|
assert session.title == "Song Title"
|
||||||
|
assert session.artist == "Some Artist"
|
||||||
|
|
||||||
|
def test_stateless_playbackstate_line_ignored(self):
|
||||||
|
"""A non-PlaybackState `state=` line must not crash or fabricate state."""
|
||||||
|
dump = (
|
||||||
|
" Some app session com.example.player/Session (userId=0)\n"
|
||||||
|
" package=com.example.player\n"
|
||||||
|
" active=true\n"
|
||||||
|
" state=null\n"
|
||||||
|
" metadata: size=1, description=Only Title\n"
|
||||||
|
)
|
||||||
|
sessions = parse_all_media_sessions(dump)
|
||||||
|
assert len(sessions) == 1
|
||||||
|
assert sessions[0].playing is False
|
||||||
|
assert sessions[0].paused is False
|
||||||
|
assert sessions[0].position is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioPlaybackSection:
|
||||||
|
"""The "Audio playback (lastly played comes first)" section lists apps
|
||||||
|
that recently played audio even with no MediaSession (R2)."""
|
||||||
|
|
||||||
|
def test_tivimate_shows_up_without_a_session(self):
|
||||||
|
packages = parse_audio_playback(_fixture("media_session_tivimate.txt"))
|
||||||
|
assert packages == [
|
||||||
|
"ar.tvplayer.tv",
|
||||||
|
"com.google.android.apps.mediashell",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_order_is_dump_order_most_recent_first(self):
|
||||||
|
packages = parse_audio_playback(_fixture("media_session_tivimate.txt"))
|
||||||
|
assert packages[0] == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
def test_wholphin_audio_playback(self):
|
||||||
|
assert parse_audio_playback(_fixture("media_session_wholphin.txt")) == [
|
||||||
|
"com.github.damontecres.wholphin"
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_lines_before_the_section_are_ignored(self):
|
||||||
|
"""`... package:` listener lines precede the section and must not leak
|
||||||
|
into the audio-playback package list."""
|
||||||
|
dump = (
|
||||||
|
" Media key listener package:\n"
|
||||||
|
" OnMediaKeyEventDispatchedListener: added 0 listener(s)\n"
|
||||||
|
" Audio playback (lastly played comes first)\n"
|
||||||
|
" uid=10117 packages=ar.tvplayer.tv\n"
|
||||||
|
)
|
||||||
|
assert parse_audio_playback(dump) == ["ar.tvplayer.tv"]
|
||||||
|
|
||||||
|
def test_empty_dump(self):
|
||||||
|
assert parse_audio_playback("") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_apps_last_known_enrichment():
|
||||||
|
"""P3-3b: apps without a live session carry their TTL-capped last-known state."""
|
||||||
|
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
{"package": "ar.tvplayer.tv"},
|
||||||
|
{"package": "com.netflix.ninja"},
|
||||||
|
]
|
||||||
|
sessions = [MediaSessionInfo(package="com.netflix.ninja", playing=True, title="Movie")]
|
||||||
|
last_known = {
|
||||||
|
"ar.tvplayer.tv": MediaSessionInfo(
|
||||||
|
package="ar.tvplayer.tv", playing=False, paused=True, title="Last show"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
apps = running_apps_from_tasks(tasks, sessions, foreground_package=None, last_known=last_known)
|
||||||
|
|
||||||
|
tivi = next(a for a in apps if a["package"] == "ar.tvplayer.tv")
|
||||||
|
netflix = next(a for a in apps if a["package"] == "com.netflix.ninja")
|
||||||
|
|
||||||
|
assert tivi["state"] == "running" # no live session
|
||||||
|
assert tivi["last_known_state"] == "paused"
|
||||||
|
assert tivi["last_known_title"] == "Last show"
|
||||||
|
assert "last_known_state" not in netflix # live session enriched instead
|
||||||
|
assert netflix["state"] == "playing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_application_label_default_locale_wins():
|
||||||
|
"""P5-1: the default-locale label wins; localized suffixes are skipped."""
|
||||||
|
from nsh.ha_adb_common.app_label import parse_application_label
|
||||||
|
|
||||||
|
dump = (
|
||||||
|
"application-label: 'TiviMate'\n"
|
||||||
|
"application-label-de: 'TiviMate DE'\n"
|
||||||
|
"application-icon-320: 'x'"
|
||||||
|
)
|
||||||
|
assert parse_application_label(dump) == "TiviMate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_application_label_tolerance():
|
||||||
|
from nsh.ha_adb_common.app_label import parse_application_label
|
||||||
|
|
||||||
|
# double-quoted, empty first label (multi-label block), nonexistent
|
||||||
|
assert parse_application_label('application-label:"Wholphin"') == "Wholphin"
|
||||||
|
multi_label = "application-label:''\napplication-label: 'TiviMate'"
|
||||||
|
assert parse_application_label(multi_label) == "TiviMate"
|
||||||
|
assert parse_application_label("application-label: null") is None
|
||||||
|
assert parse_application_label("") is None
|
||||||
|
assert parse_application_label("nothing here") is None
|
||||||
|
|
||||||
|
|
||||||
|
def _logcat_lines():
|
||||||
|
"""Synthetic logcat list covering START shapes + noise + wrapping."""
|
||||||
|
return [
|
||||||
|
"03-21 10:19:40.100 3104 3456 I NotificationService: noise",
|
||||||
|
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager: START u0 "
|
||||||
|
"{cmp=com.netflix.ninja/com.netflix.ui.main flg=0x10000000}",
|
||||||
|
"03-21 10:19:45.100 3104 3456 I ActivityTaskManager: START u0 {cmp=null}",
|
||||||
|
"03-21 10:19:47.000 3104 3456 I ActivityTaskManager: START u10 "
|
||||||
|
"{cmp=ar.tvplayer.tv/.ui.MainActivity",
|
||||||
|
" xflg=214 abi=arm64-v8a}",
|
||||||
|
"03-21 10:19:50.000 3104 3456 I ActivityTaskManager: Displayed",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_logcat_start_records_parse_cmp():
|
||||||
|
from nsh.ha_adb_common.logcat_events import foreground_from_logcat, foreground_packages
|
||||||
|
|
||||||
|
lines = _logcat_lines()
|
||||||
|
assert foreground_packages(lines) == ["com.netflix.ninja", "ar.tvplayer.tv"]
|
||||||
|
# LAST valid START wins
|
||||||
|
assert foreground_from_logcat(lines) == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logcat_non_start_and_null_ignored():
|
||||||
|
from nsh.ha_adb_common.logcat_events import foreground_from_logcat
|
||||||
|
|
||||||
|
assert foreground_from_logcat([
|
||||||
|
"03-21 10:19:50.000 3104 3456 I ActivityTaskManager: Displayed",
|
||||||
|
"plain noise",
|
||||||
|
]) is None
|
||||||
|
lines = _logcat_lines()
|
||||||
|
assert foreground_from_logcat(lines) == "ar.tvplayer.tv" # null skipped
|
||||||
|
|
||||||
|
|
||||||
|
def test_logcat_multi_user_prefix_tolerated():
|
||||||
|
from nsh.ha_adb_common.logcat_events import foreground_from_logcat
|
||||||
|
|
||||||
|
assert foreground_from_logcat(
|
||||||
|
["03-21 10:19:41.792 3104 3456 I ActivityTaskManager: START u123 {cmp=net.whatsapp/"] + [
|
||||||
|
" flg=0x1}"]
|
||||||
|
) == "net.whatsapp"
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Unit tests for the protocol transport (androidtvremote2, Phase 2, t8).
|
||||||
|
|
||||||
|
Fake client mirrors AndroidTVRemote's surface (callback registration,
|
||||||
|
``keep_reconnecting`` that owns its own reconnect task, sync ``disconnect``);
|
||||||
|
no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization
|
||||||
|
against the real library's VolumeInfo TypedDict when the package is present
|
||||||
|
(`uvx --with androidtvremote2 pytest`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from nsh.connection import (
|
||||||
|
FIELD_AWAKE,
|
||||||
|
FIELD_IS_AVAILABLE,
|
||||||
|
FIELD_IS_ON,
|
||||||
|
FIELD_SESSIONS,
|
||||||
|
FIELD_VOLUME_INFO,
|
||||||
|
ShieldConnectionManager,
|
||||||
|
StateStore,
|
||||||
|
volume_tuple,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProtocolClient:
|
||||||
|
"""AndroidTVRemote-shaped fake: callbacks + self-owned reconnect task."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._is_on_callbacks: list = []
|
||||||
|
self._is_available_callbacks: list = []
|
||||||
|
self._current_app_callbacks: list = []
|
||||||
|
self._volume_callbacks: list = []
|
||||||
|
self._invalid_auth_callback = None
|
||||||
|
self._reconnect_task = None
|
||||||
|
self.disconnect_calls = 0
|
||||||
|
|
||||||
|
# -- registration (mirrors the real API) --
|
||||||
|
def add_is_on_updated_callback(self, callback):
|
||||||
|
self._is_on_callbacks.append(callback)
|
||||||
|
|
||||||
|
def add_is_available_updated_callback(self, callback):
|
||||||
|
self._is_available_callbacks.append(callback)
|
||||||
|
|
||||||
|
def add_current_app_updated_callback(self, callback):
|
||||||
|
self._current_app_callbacks.append(callback)
|
||||||
|
|
||||||
|
def add_volume_info_updated_callback(self, callback):
|
||||||
|
self._volume_callbacks.append(callback)
|
||||||
|
|
||||||
|
# -- pushes (test helper: simulates device-side messages) --
|
||||||
|
def push_is_on(self, on):
|
||||||
|
for cb in list(self._is_on_callbacks):
|
||||||
|
cb(on)
|
||||||
|
|
||||||
|
def push_is_available(self, available):
|
||||||
|
for cb in list(self._is_available_callbacks):
|
||||||
|
cb(available)
|
||||||
|
|
||||||
|
def push_current_app(self, app_package):
|
||||||
|
for cb in list(self._current_app_callbacks):
|
||||||
|
cb(app_package)
|
||||||
|
|
||||||
|
def push_volume_info(self, info):
|
||||||
|
for cb in list(self._volume_callbacks):
|
||||||
|
cb(info)
|
||||||
|
|
||||||
|
# -- lifecycle --
|
||||||
|
def keep_reconnecting(self, invalid_auth_callback=None):
|
||||||
|
"""Own task like the real library (0.1 s → 30 s backoff internally)."""
|
||||||
|
self._invalid_auth_callback = invalid_auth_callback
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self._reconnect_task = loop.create_task(self._reconnect_loop())
|
||||||
|
|
||||||
|
async def _reconnect_loop(self):
|
||||||
|
while True: # the real task sleeps through its internal backoff
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
def fire_invalid_auth(self):
|
||||||
|
self._invalid_auth_callback()
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.disconnect_calls += 1
|
||||||
|
if self._reconnect_task is not None:
|
||||||
|
self._reconnect_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def _protocol_manager(client=None, **kwargs) -> ShieldConnectionManager:
|
||||||
|
return ShieldConnectionManager(hass=None, entry=None, protocol=client, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def _drain_loop():
|
||||||
|
"""Let call_soon_threadsafe callbacks scheduled via the running loop run."""
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPushCallbackWiring:
|
||||||
|
async def test_is_on_and_availability_push_into_store(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
notified = []
|
||||||
|
manager.register_listener(lambda: notified.append(True))
|
||||||
|
|
||||||
|
await manager._start_protocol()
|
||||||
|
client.push_is_on(True)
|
||||||
|
client.push_is_available(False)
|
||||||
|
await _drain_loop()
|
||||||
|
|
||||||
|
assert manager.is_on is True
|
||||||
|
assert manager.is_available is False
|
||||||
|
assert manager.store.get(FIELD_IS_ON) is True
|
||||||
|
assert notified, "listeners pinged from protocol pushes"
|
||||||
|
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_current_app_push_stored_raw_package(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
await manager._start_protocol()
|
||||||
|
client.push_current_app("com.netflix.ninja")
|
||||||
|
await _drain_loop()
|
||||||
|
assert manager.current_app == "com.netflix.ninja" # name resolution deferred
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_volume_push_normalized_to_level_max_muted(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
await manager._start_protocol()
|
||||||
|
client.push_volume_info({"level": 7, "max": 15, "muted": True})
|
||||||
|
await _drain_loop()
|
||||||
|
assert manager.volume_info == (7.0, 15.0, True)
|
||||||
|
assert manager.store.get(FIELD_VOLUME_INFO)[2] is True
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvalidAuthHook:
|
||||||
|
async def test_invalid_auth_invokes_entry_callback(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
fired = []
|
||||||
|
manager = _protocol_manager(
|
||||||
|
client, invalid_auth_callback=lambda: fired.append(True)
|
||||||
|
)
|
||||||
|
await manager._start_protocol()
|
||||||
|
|
||||||
|
client.fire_invalid_auth()
|
||||||
|
|
||||||
|
assert fired == [True], "reauth hook fired, not a retry storm (t9 delegates)"
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_invalid_auth_without_callback_is_safe(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
await manager._start_protocol()
|
||||||
|
client.fire_invalid_auth() # no callback registered — must not raise
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestLifecycle:
|
||||||
|
async def test_stop_disconnects_lib_owned_reconnect_task(self):
|
||||||
|
"""The library owns keep_reconnecting's task; closing the client
|
||||||
|
tears it down — the manager never wraps that task itself."""
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
await manager._start_protocol()
|
||||||
|
task = client._reconnect_task
|
||||||
|
assert task is not None
|
||||||
|
|
||||||
|
await manager.async_stop()
|
||||||
|
await _drain_loop() # let the lib-owned task settle its cancellation
|
||||||
|
|
||||||
|
assert client.disconnect_calls == 1
|
||||||
|
assert task.cancelled()
|
||||||
|
assert manager._protocol is None
|
||||||
|
|
||||||
|
async def test_protocol_with_factory_settings_builds_client(self, monkeypatch):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
built = []
|
||||||
|
|
||||||
|
def fake_builder(*args, **kwargs):
|
||||||
|
built.append(kwargs)
|
||||||
|
return client
|
||||||
|
|
||||||
|
monkeypatch.setattr(ShieldConnectionManager, "build_protocol_client", fake_builder)
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None,
|
||||||
|
entry=None,
|
||||||
|
protocol_settings={
|
||||||
|
"host": "shield.local",
|
||||||
|
"client_name": "Home Assistant",
|
||||||
|
"certfile": "/tmp/cert.pem",
|
||||||
|
"keyfile": "/tmp/key.pem",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await manager._start_protocol()
|
||||||
|
|
||||||
|
assert manager._protocol is client
|
||||||
|
assert built
|
||||||
|
kwargs = built[0]
|
||||||
|
assert kwargs["host"] == "shield.local"
|
||||||
|
assert kwargs["client_name"] == "Home Assistant"
|
||||||
|
assert kwargs["certfile"] == "/tmp/cert.pem"
|
||||||
|
assert kwargs["keyfile"] == "/tmp/key.pem"
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_passive_without_protocol_configuration(self):
|
||||||
|
manager = _protocol_manager(client=None)
|
||||||
|
await manager._start_protocol() # no factory, no settings → no-op
|
||||||
|
|
||||||
|
assert manager._protocol is None
|
||||||
|
assert manager.is_on is None
|
||||||
|
assert manager.is_available is None
|
||||||
|
assert manager.current_app is None
|
||||||
|
assert manager.volume_info is None
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPushEdgeCases:
|
||||||
|
async def test_push_after_loop_close_falls_back_to_direct_notify(self):
|
||||||
|
"""Push callbacks that outlive a closed loop must not crash."""
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
notified = []
|
||||||
|
manager.register_listener(lambda: notified.append(True))
|
||||||
|
|
||||||
|
await manager._start_protocol()
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
loop.close()
|
||||||
|
manager._protocol_loop = loop # stale/closed loop (mid-shutdown state)
|
||||||
|
|
||||||
|
client.push_is_on(True)
|
||||||
|
await _drain_loop()
|
||||||
|
|
||||||
|
assert manager.is_on is True # store stamped via direct-notify path
|
||||||
|
assert notified # listeners still pinged synchronously
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_store_ttl_applies_to_protocol_fields(self):
|
||||||
|
now = [1000.0]
|
||||||
|
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client, store=store)
|
||||||
|
await manager._start_protocol()
|
||||||
|
client.push_is_on(True)
|
||||||
|
await _drain_loop()
|
||||||
|
|
||||||
|
now[0] += 60.1
|
||||||
|
assert manager.is_on is None # expired like every other store field
|
||||||
|
assert manager.store.get(FIELD_IS_AVAILABLE) is None
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_pushes_are_stamped_with_transition_time(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
await manager._start_protocol()
|
||||||
|
before = time.monotonic()
|
||||||
|
client.push_is_available(True)
|
||||||
|
await _drain_loop()
|
||||||
|
after = time.monotonic()
|
||||||
|
stamped_at = manager.store._values[FIELD_IS_AVAILABLE][0]
|
||||||
|
assert before <= stamped_at <= after
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAdbForOutage:
|
||||||
|
"""ADB poller stub for the outage test (wake poll fails)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.connected = True
|
||||||
|
self.awake = None
|
||||||
|
self.disconnect_calls = 0
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
self.connected = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
self.disconnect_calls += 1
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
async def is_awake(self):
|
||||||
|
return self.awake if self.connected else None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdbOutageKeepsProtocolTruth:
|
||||||
|
async def test_note_failure_clears_only_adb_fields(self):
|
||||||
|
"""F2: an ADB outage must not clobber device-reported protocol truth."""
|
||||||
|
poller = FakeAdbForOutage()
|
||||||
|
poller.connected = True
|
||||||
|
poller.awake = None # wake poll fails → cycle failure path
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, protocol=client
|
||||||
|
)
|
||||||
|
await manager._start_protocol()
|
||||||
|
|
||||||
|
client.push_is_on(True)
|
||||||
|
client.push_is_available(True)
|
||||||
|
client.push_current_app("com.netflix.ninja")
|
||||||
|
client.push_volume_info({"level": 5, "max": 15, "muted": False})
|
||||||
|
manager.store.set(FIELD_AWAKE, True)
|
||||||
|
manager.store.set(FIELD_SESSIONS, ["session-data"])
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0) # ADB outage → _note_failure
|
||||||
|
|
||||||
|
# ADB-derived fields cleared (regenerated by the watchdog)
|
||||||
|
assert manager.store.get(FIELD_AWAKE) is None
|
||||||
|
assert manager.store.get(FIELD_SESSIONS, []) == []
|
||||||
|
# protocol push truth survives the ADB outage (TTL expiry rules it)
|
||||||
|
assert manager.is_on is True
|
||||||
|
assert manager.is_available is True
|
||||||
|
assert manager.current_app == "com.netflix.ninja"
|
||||||
|
assert manager.volume_info == (5.0, 15.0, False)
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportGuard:
|
||||||
|
def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog):
|
||||||
|
"""F1: a protocol-enabled entry without androidtvremote2 keeps
|
||||||
|
running ADB-only — the guarded factory logs a warning and builds
|
||||||
|
nothing instead of raising at setup."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
client = ShieldConnectionManager.build_protocol_client(
|
||||||
|
"shield.local", "Home Assistant", "/tmp/c.pem", "/tmp/k.pem"
|
||||||
|
)
|
||||||
|
assert client is None
|
||||||
|
assert any("degrades to ADB-only" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
async def test_settings_entry_without_library_stays_passive(self, monkeypatch):
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None,
|
||||||
|
entry=None,
|
||||||
|
protocol_settings={
|
||||||
|
"host": "shield.local",
|
||||||
|
"client_name": "HA",
|
||||||
|
"certfile": "/tmp/c.pem",
|
||||||
|
"keyfile": "/tmp/k.pem",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
|
||||||
|
await manager._start_protocol()
|
||||||
|
assert manager._protocol is None # passive, no crash
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolumeTuple:
|
||||||
|
def test_normalizes_typed_dict(self):
|
||||||
|
assert volume_tuple({"level": 7, "max": 15, "muted": True}) == (7.0, 15.0, True)
|
||||||
|
|
||||||
|
def test_defaults_on_partial_dict(self):
|
||||||
|
assert volume_tuple({"muted": False}) == (0.0, 1.0, False)
|
||||||
|
|
||||||
|
def test_none_and_garbage_yield_none(self):
|
||||||
|
assert volume_tuple(None) is None
|
||||||
|
assert volume_tuple("garbage") is None
|
||||||
|
|
||||||
|
def test_accepts_real_library_volume_info(self):
|
||||||
|
androidtvremote2 = pytest.importorskip("androidtvremote2")
|
||||||
|
info = androidtvremote2.VolumeInfo(level=3, max=15, muted=True)
|
||||||
|
assert volume_tuple(info) == (3.0, 15.0, True)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Tests for the running-apps list builder (recent tasks + media sessions)."""
|
||||||
|
|
||||||
|
from ha_adb_common.running_apps import running_apps_from_tasks
|
||||||
|
|
||||||
|
|
||||||
|
def _session(package: str, playing: bool = False, title: str | None = None):
|
||||||
|
from ha_adb_common.media_session import MediaSessionInfo
|
||||||
|
|
||||||
|
s = MediaSessionInfo(package=package)
|
||||||
|
s.playing = playing
|
||||||
|
s.paused = not playing
|
||||||
|
s.title = title
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_for_fewer_than_two_tasks():
|
||||||
|
assert running_apps_from_tasks([]) is None
|
||||||
|
assert running_apps_from_tasks([{"package": "app.a"}]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_foreground_and_defaults_to_running():
|
||||||
|
tasks = [{"package": "app.a"}, {"package": "app.b"}]
|
||||||
|
apps = running_apps_from_tasks(tasks, [], foreground_package="app.b")
|
||||||
|
assert apps is not None
|
||||||
|
assert len(apps) == 2
|
||||||
|
by_pkg = {a["package"]: a for a in apps}
|
||||||
|
assert "foreground" not in by_pkg["app.a"]
|
||||||
|
assert by_pkg["app.b"]["foreground"] is True
|
||||||
|
assert by_pkg["app.a"]["state"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enriches_with_media_session_state():
|
||||||
|
tasks = [{"package": "app.a"}, {"package": "app.b"}]
|
||||||
|
sessions = [_session("app.a", playing=True, title="Song")]
|
||||||
|
apps = running_apps_from_tasks(tasks, sessions)
|
||||||
|
assert apps is not None
|
||||||
|
by_pkg = {a["package"]: a for a in apps}
|
||||||
|
assert by_pkg["app.a"]["state"] == "playing"
|
||||||
|
assert by_pkg["app.a"]["title"] == "Song"
|
||||||
|
assert by_pkg["app.b"]["state"] == "running"
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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