Files
ha-nvidia-shield/custom_components/nvidia_shield/const.py
T
James Mackay 89c05f1dcd feat(media_player): per-command script overrides (t44)
The user's remaining universal-media-player dependency: some commands need hardware
this integration deliberately does not drive — a speaker switch that owns the
volume, an AVR input that has to move with the Shield, a mute that has to hit two
devices. One script per command is the escape hatch, and the rule is absolute: a
configured command calls its script INSTEAD of the native protocol/ADB path, and
with nothing configured every command behaves exactly as it did (the whole existing
suite passes untouched, which is what "byte-identical" means here).

* const.py owns the option: CONF_COMMAND_SCRIPTS (one dict, keyed by command),
  SCRIPT_COMMANDS (turn_on, turn_off, volume_up, volume_down, volume_set,
  volume_mute, select_source) and command_scripts(), the ONE normalizer of the
  stored shape — options round-trip through HA storage and can be hand-edited, so a
  value that is not a non-empty string for a known command is dropped instead of
  reaching the service layer.
* media_player.py: async_run_command_script() calls script.turn_on with the script
  entity and its variables, blocking=True so a failure surfaces HERE — a mapped
  command that cannot run raises HomeAssistantError naming the command and script
  rather than falling back silently or doing nothing. Every command method checks
  its script first; volume_set passes `level` and select_source passes `app_name`.
* volume_mute also passes `is_volume_muted`: without it a mute script cannot tell
  muting from unmuting, which is the case the user asked for.
* volume_set and select_source have NO native path (C-VOL; app launching goes
  through play_media), so they are advertised only when configured — the feature
  flag is what makes HA offer the command at all. Unset leaves the feature set
  exactly as it was.
* remote.py shares async_run_command_script for POWER: turn_on/turn_off is one
  configuration and the remote entity is the other route to it, so a script that
  owns the power sequence owns it on both.
* config_flow.py: one entity selector per command (domain: script), stored as the
  single dict option, prefilled from the entry's options and cleared by saving
  without a value — the editor is the truth, as with the app map editor.

Two things the harness caught, worth recording: `MediaPlayerEntityFeature` has no
SET_VOLUME (the real member is VOLUME_SET — the stub enum and the drift engine
disagree with a wrong name), and the stub `homeassistant.exceptions` had no
HomeAssistantError, so the stub now mirrors the real inheritance
(ServiceValidationError derives from it).

tests/test_command_scripts.py covers each command mapped and unmapped, the exact
variable payloads, the native path untouched when unmapped (protocol key, then the
ADB fallthrough with the protocol down), the rejecting script's error, hand-edited
option shapes, and the feature set. test_config_flow_ux.py covers the selectors,
the stored shape, prefill and clearing. New tests leaked the harness into the next
file at first (a missing per-file scope); that is fixed, and
tests/test_harness_scoping.py now asserts that every file loading the harness scopes
it, so the next such mistake fails where it is made.

Verified: 401 passed / 2 skipped, 402 / 1 with androidtvremote2, reverse file order
401 / 2, ruff clean, and the drift engine exits 0 on a real-HA interpreter
(23 modules, 59 imports, 157 relative imports, 36 call arguments, 6 awaited HA calls).
2026-10-11 06:47:20 +01:00

206 lines
7.7 KiB
Python

"""Constants for the Nvidia Shield integration."""
# Re-export shared Media DAG constants
from .ha_adb_common.const import ( # noqa: F401
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS,
)
DOMAIN = "nvidia_shield"
# t41: the INTEGRATION's address field — one Shield IP for the whole stack
# (protocol + ADB). The two keys below it are LEGACY: a YAML seed may still
# carry them and the v3 entry migration maps them onto CONF_HOST, but no
# runtime code reads them.
CONF_HOST = "host"
CONF_ADB_HOST = "adb_host"
CONF_PROTOCOL_HOST = "protocol_host"
# t41 legacy: dropped from entries by the v3 migration (the androidtv entity
# path it configured is gone), never read at runtime.
CONF_ANDROIDTV_ENTITY = "androidtv_entity"
CONF_JELLYFIN_ENTITY = "jellyfin_entity"
CONF_RECEIVER_ENTITY = "receiver_entity"
CONF_RECEIVER_INPUT = "receiver_input"
CONF_POWER_ENTITY = "power_entity"
CONF_ADB_ENABLED = "adb_enabled"
CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
CONF_ADB_PORT = "adb_port"
# Remote-protocol transport (androidtvremote2) — Phase 2
CONF_PROTOCOL_ENABLED = "protocol_enabled"
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_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
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"
# Config-entry schema version (t41): v1 → v2 added the remote-protocol fields,
# v2 → v3 collapsed the two address fields into CONF_HOST and dropped the
# androidtv-entity key. One constant so the flow and the migration cannot drift.
ENTRY_VERSION = 3
# Emotiva DAG attribute we read to get available inputs
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
# Known Jellyfin app package IDs (used to route Jellyfin-family clients
# — including Wholphin, a Jellyfin client — to the rich-metadata path).
JELLYFIN_APP_IDS = [
"org.jellyfin.androidtv",
"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",
]
# t44: per-command script overrides — the escape hatch for hardware this
# integration deliberately does not drive itself (a speaker switch that owns the
# volume, an AVR input, a mute that has to hit two devices). One script per
# command: when one is configured, that command calls the script INSTEAD of the
# native protocol/ADB path, and with none configured nothing changes at all.
CONF_COMMAND_SCRIPTS = "command_scripts"
SCRIPT_COMMANDS = (
"turn_on",
"turn_off",
"volume_up",
"volume_down",
"volume_set",
"volume_mute",
"select_source",
)
#: Options-flow field name per command: ``script_turn_on``, ``script_volume_set``…
SCRIPT_FIELD_PREFIX = "script_"
#: Variables a command hands its script (HA script variables). volume_mute passes
#: whether it is muting or unmuting — a script cannot tell those apart otherwise.
SCRIPT_VAR_LEVEL = "level"
SCRIPT_VAR_APP_NAME = "app_name"
SCRIPT_VAR_MUTED = "is_volume_muted"
def command_scripts(options) -> dict[str, str]:
"""The script configured per command, normalized (t44).
The ONE place the stored option's shape is read: the options flow, the media
player and the remote entity all go through it. Options round-trip through HA
storage and can be hand-edited, so a value that is not a non-empty string for a
known command is dropped rather than handed to the service layer.
"""
raw = (options or {}).get(CONF_COMMAND_SCRIPTS)
if not isinstance(raw, dict):
return {}
scripts: dict[str, str] = {}
for command in SCRIPT_COMMANDS:
script = raw.get(command)
if isinstance(script, str) and script.strip():
scripts[command] = script.strip()
return scripts