The two address fields were the same Shield spelled twice, and every bug in the zeroconf/IP-change path came from keeping them in sync. One field now: * const.CONF_HOST replaces CONF_ADB_HOST / CONF_PROTOCOL_HOST in the setup form, the options page, the pairing forms, the reconfigure/reauth seeding and the description copy. ADB port 5555 stays the only port field; * helpers.entry_host(entry) is the single resolver (options first, then data); resolve_adb_config and the protocol settings both go through it, so both transports read the same value at call time (the t32 F1 factory re-read keeps working: the NEXT built poller sees the new address); * helpers.seed_host / seed_adb_enabled own the YAML seed's shape rule. A block may use `host` or the legacy protocol_host/adb_host keys — t41 keeps the old spelling working — and t32 F4's outcome is unchanged: a protocol-only block leaves ADB off, an address (or an explicit adb_enabled) turns it on; * the zeroconf IP-change path collapses the t21-F2 per-host dance into one rule: the discovery address IS the entry's address, so the entry is updated when it differs and left alone when it matches. The blank-self-heal and deliberately-different-ADB-host cases are gone with the duplication — a second address field cannot express them anymore, and the tests now pin that (previously "an explicit override is never clobbered"); * the options page exposes the protocol toggle it never had (the page could turn ADB off but not the protocol); * button.py (granted to this task) reads the entry's own address instead of another integration's entity. The v3 entry migration that maps existing adb_host/protocol_host onto CONF_HOST and drops the androidtv key lands in the next commit; HA runs migrations before setup, so an existing entry is never read with the old shape. Verified: 311 passed / 2 skipped, 312 / 1 with androidtvremote2, ruff clean, drift engine exit 0 on HEAD. The UI-copy contract tests (every rendered field has a label in strings.json AND translations/en.json, key trees identical, host errors name the field) drove the string edits.
247 lines
8.5 KiB
Python
247 lines
8.5 KiB
Python
"""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 os
|
|
|
|
from homeassistant import config_entries
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
from .adb_session import AdbMediaSessionPoller
|
|
from .connection import ShieldConnectionManager
|
|
from .const import (
|
|
CONF_ADB_ENABLED,
|
|
CONF_ADB_HOST,
|
|
CONF_ADB_PORT,
|
|
CONF_HOST,
|
|
CONF_PROTOCOL_ENABLED,
|
|
DEFAULT_ADB_PORT,
|
|
DEFAULT_NAME,
|
|
DOMAIN,
|
|
SERVICE_ADB_COMMAND,
|
|
)
|
|
from .helpers import (
|
|
entry_host,
|
|
protocol_cert_paths,
|
|
resolve_adb_config,
|
|
seed_adb_enabled,
|
|
seed_host,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
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 seed_host(block):
|
|
_LOGGER.warning(
|
|
"nvidia_shield YAML block without an address 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. t41: the address is ONE
|
|
# field (`host`, or a legacy protocol_host/adb_host).
|
|
seed = {
|
|
"name": name,
|
|
CONF_ADB_ENABLED: seed_adb_enabled(block),
|
|
CONF_HOST: seed_host(block),
|
|
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
|
|
}
|
|
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:
|
|
"""Set up Nvidia Shield from a config entry."""
|
|
hass.data.setdefault(DOMAIN, {})
|
|
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)
|
|
|
|
return True
|
|
|
|
|
|
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
|
"""Manager protocol_settings when the transport is enabled & resolvable.
|
|
|
|
The address is the entry's own single field (t41). Nothing is derived from
|
|
another integration.
|
|
"""
|
|
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
|
return None
|
|
|
|
host = entry_host(entry)
|
|
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)
|
|
# t41: the entry's address is the single CONF_HOST field; a v1 entry
|
|
# carried it under the legacy adb_host key.
|
|
if not options.get(CONF_HOST):
|
|
options[CONF_HOST] = options.get(CONF_ADB_HOST) or ""
|
|
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
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Unload a config entry."""
|
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
|
|
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)
|
|
|
|
return unload_ok
|
|
|
|
|