Files
ha-nvidia-shield/custom_components/nvidia_shield/const.py
T
James Mackay f29f62ddae feat(image): opt-in screen capture as an image entity, binary-safe over ADB
The user asked for "an image entity of the device screen capture? Can that be
added". It is added as `image.<entry>_screen_capture`, and the three boundaries
are the feature:

- it NEVER drives state — the state is the capture timestamp, and the capture
  path reads the ADB poller and nothing else (pinned by a manager that raises on
  any other attribute, and by a real-HA run whose recorded state is the
  timestamp with no bytes anywhere in the attributes);
- it is OPT-IN and OFF by default (`screen_capture_enabled`, Main setup row) —
  with the option absent `async_setup_entry` adds no entity at all;
- it NEVER polls — HA's own `should_poll = False` for image entities is kept, so
  only an image fetch or `homeassistant.update_entity` captures; concurrent
  fetches share one shell and a capture is reused for 5 s.

BINARY-SAFE CAPTURE: `screencap -p | base64` over the existing shell, decoded
locally with `base64.b64decode` and gated on the PNG signature AND the IHDR
chunk. `AdbDevice.shell()` returns `str`, so raw PNG bytes cannot travel through
it; the alternative (a device temp file + the library's file-read path) needs the
raw AdbDevice and writes to the Shield's shared storage, where a crash between
write and delete leaves a screenshot behind. The base64 route leaves no
device-side artifact — nothing to clean up — at the cost of ~33% more bytes on
the wire.

HONEST FAILURES: ADB off / ADB down / a capture that returned no PNG / a
transport that raised all make the entity UNAVAILABLE with `capture_status`
(`adb_disabled` / `adb_disconnected` / `capture_failed`) — never a blank or
zero-byte image. `image_last_updated` moves only on success.

Tests: 25 new cases (a real 2x1 PNG fixture on the wire — bytes, never a str;
every non-PNG answer rejected; disconnected/failed/recovery paths; capture reuse,
coalescing and the expiry window; opt-in default OFF and on) plus 4 config-flow
cases (schema default False, off stays off, on is stored, and it can be turned
back off). The stub harness gains the image platform and `util.dt` faces, the
real-HA entity matrix gains this class, and the drift sweep's coverage test
demanded exactly that. README documents the cost, the opt-in and the boundary.
2026-10-12 10:28:32 +01:00

283 lines
12 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
# t57 UNIT 0: exit marker appended to the label dump so a FAILED fetch can be
# told from a package that genuinely has no label. ``adb shell`` output ending
# in this marker means the command ran; an empty answer still carries it, and
# a transport that never delivered output does not. The value is arbitrary but
# unlikely to collide with a manifest line.
APP_LABEL_EXIT_MARKER = "__NSH_EXIT:"
# 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",
}
# t57: best-effort icon per discovered app. `pm dump` does NOT expose the
# app's icon resource (an extracted APK's icon is a bitmap only its own code
# can resolve, and `dumpsys package` prints no drawable reference), so a device
# icon is not obtainable over plain shell. These MDI names are therefore a
# HEURISTIC: a curated package -> MDI mapping with a generic fallback, honest
# about being a guess. The user's own `app_icon_map` (a picture URL or camera
# entity, see CONF_APP_ICON_MAP) always outranks it for entity_picture.
APP_ICON_FALLBACKS = {
"ar.tvplayer.tv": "mdi:television-classic",
"com.github.damontecres.wholphin": "mdi:jellyfish",
"com.netflix.ninja": "mdi:netflix",
"com.google.android.youtube.tv": "mdi:youtube-tv",
"com.google.android.tvlauncher": "mdi:view-dashboard",
"org.xbmc.kodi": "mdi:kodi",
"com.plexapp.android": "mdi:plex",
"com.amazon.amazonvideo.livingroom": "mdi:amazon",
"com.disney.disneyplus": "mdi:movie-open",
"com.spotify.tv.android": "mdi:spotify",
"com.google.android.apps.youtube.music": "mdi:music",
"com.valvesoftware.steamlink": "mdi:gamepad-variant",
"com.spotify.music": "mdi:spotify",
}
#: The fallback for an app this map has never heard of.
APP_ICON_GENERIC = "mdi:television"
# t57: installed-app discovery. The package list changes only when the user
# installs or uninstalls something, so a long TTL is right; refreshing is
# explicit (the options page and the app-list sensor ask for it) rather than a
# per-poll cost.
APP_LIST_TTL = 6 * 3600
#: `pm dump` sweeps for labels run in a small burst — enough to finish a
#: typical Shield quickly, few enough that the label fetches never queue
#: behind each other on one socket or stall the watchdog.
APP_SWEEP_CONCURRENCY = 4
#: Spacing between the fetches of one sweep, per worker. Keeps the burst a
#: trickle on the one socket the watchdog uses, while ~40 apps still finish in
#: a few seconds instead of a few minutes.
APP_SWEEP_SPACING = 0.05
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
# t47: ONE optional script for the remote entity's send_command. It replaces the
# whole key path (repeats, hold, delays included) rather than per-key overrides,
# because a remote command is a sequence: an IR bridge, a macro or a second device
# needs the call, not individual keys.
CONF_REMOTE_COMMAND_SCRIPT = "remote_command_script"
#: Variables remote.send_command hands its script — the names are the service's own
#: parameter names, and they are documented in the README and pinned in tests.
SCRIPT_VAR_COMMAND = "command"
SCRIPT_VAR_NUM_REPEATS = "num_repeats"
SCRIPT_VAR_DELAY_SECS = "delay_secs"
SCRIPT_VAR_HOLD_SECS = "hold_secs"
# t66: the screen-capture image entity — OFF by default. A capture is a
# full-resolution screen grab, and the user's standing constraint is that screen
# capture must never drive state; opt-in keeps a diagnostic surface from costing
# every install a grab per dashboard view.
CONF_SCREEN_CAPTURE_ENABLED = "screen_capture_enabled"
def remote_command_script(options) -> str | None:
"""The script configured for remote.send_command, or None (t47).
Same defensive read as ``command_scripts``: options round-trip through HA
storage and can be hand-edited, so anything that is not a non-empty string is
"not configured" rather than a service call to nowhere.
"""
script = (options or {}).get(CONF_REMOTE_COMMAND_SCRIPT)
if isinstance(script, str) and script.strip():
return script.strip()
return None