From f29f62ddae128218c0004d762787b86cbe4be840 Mon Sep 17 00:00:00 2001 From: James Mackay Date: Mon, 12 Oct 2026 10:28:32 +0100 Subject: [PATCH] feat(image): opt-in screen capture as an image entity, binary-safe over ADB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user asked for "an image entity of the device screen capture? Can that be added". It is added as `image._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. --- README.md | 49 ++- custom_components/nvidia_shield/__init__.py | 11 +- .../nvidia_shield/config_flow.py | 9 + custom_components/nvidia_shield/const.py | 5 + custom_components/nvidia_shield/image.py | 280 +++++++++++++ custom_components/nvidia_shield/strings.json | 7 + .../nvidia_shield/translations/en.json | 7 + tests/entity_stublib.py | 97 +++++ tests/test_config_flow_protocol.py | 1 + tests/test_config_flow_ux.py | 45 +- tests/test_ha_api_drift.py | 62 +++ tests/test_screen_capture_image.py | 383 ++++++++++++++++++ tests/test_standalone_setup.py | 1 + 13 files changed, 954 insertions(+), 3 deletions(-) create mode 100644 custom_components/nvidia_shield/image.py create mode 100644 tests/test_screen_capture_image.py diff --git a/README.md b/README.md index 137ef8a..24825a9 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,52 @@ 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. +### Screen capture (opt-in, manual) + +The integration can expose an `image` entity showing the Shield's current screen — +`sensor`-free and **off by default**. Turn it on in **Configure → Main setup → +"Add a screen capture image entity"**; the entity appears as +`image._screen_capture` on the existing device. + +Three things about it are deliberate, and they are the whole design: + +- **It never drives state.** An `image` entity's state is the timestamp of its last + successful capture. Nothing about a capture writes to the media player, the + sensors, the power switch or the manager's store — the capture path reads the ADB + poller and nothing else. Your media player keeps saying what the DEVICE reported, + not what a screenshot happened to show. +- **It never polls.** HA sets `should_poll = False` for image entities and this one + keeps it: no scan interval, no timer. A capture happens only when something asks + for the bytes (the dashboard showing the entity picture) or when you call + `homeassistant.update_entity` on it. Back-to-back requests are coalesced into one + capture and a successful capture is reused for 5 seconds, so a dashboard repaint + cannot hammer the Shield. +- **What it costs.** Every fresh capture is a full-resolution screen grab + (`screencap -p`) of the current display, base64-encoded, pulled over the same ADB + socket the state polling uses, and it keeps that socket busy for the duration. On + a 1080p UI that is typically a few hundred kilobytes of PNG (roughly a third more + on the wire because of the base64). Treat it as a diagnostic surface you look at + when something looks wrong, not as a camera. + +It is unavailable — never a blank image — whenever ADB is off, ADB is down, or the +last capture failed; the `capture_status` attribute says which (`ok`, +`adb_disabled`, `adb_disconnected`, `capture_failed`). An explicit +`homeassistant.update_entity` retries after a failure. + +**How it captures, and why that matters.** The transport is `screencap -p | +base64` over the existing shell, decoded locally with `base64.b64decode` and +checked for the PNG signature and the `IHDR` chunk before anything is handed to HA. +The alternative — writing a temp PNG on the device and pulling it as a file — needs +the raw ADB device object 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 at all. The cost is the extra ~33% on the wire, and one thing +is **inferred rather than measured**: no Shield was available in the development +environment, so how the persistent socket copes with a few hundred kilobytes in one +`shell()` call has not been observed on hardware. The decode, the failure paths and +the entity contract are covered by tests, including a real-HA run that drives HA's +own `ImageEntity` and content-type validator. + + ## Replacing androidtv / androidtv_remote + the glue This fork replaces the old `androidtv` media player, the `androidtv_remote` @@ -310,7 +356,7 @@ and only **Save and close** commits: | Row | What it holds | | --- | --- | -| **Main setup** | Shield address, ADB port, `Read state over ADB`, `Add the Nvidia Remote Protocol`, `Show title and artist`, `Allow the adb_command action`, Jellyfin player, power switch | +| **Main setup** | Shield address, ADB port, `Read state over ADB`, `Add the Nvidia Remote Protocol`, `Show title and artist`, `Allow the adb_command action`, `Add a screen capture image entity` (off by default — see [Screen capture](#screen-capture-opt-in-manual)), Jellyfin player, power switch | | **Receiver** | the receiver/soundbar entity and the input it uses | | **Apps** | the apps editor — add, edit or remove one app, or paste the whole list (see below) | | **Scripts** | one optional script per command, plus the remote-entity script, the channel digit delay and send-ENTER | @@ -638,6 +684,7 @@ exactly as before. | Current app (`sensor.current_app`) | `sensor` | Currently running app | | Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string, with its own flat attributes (below) | | Pair ADB | `button` | Initiate or re-pair ADB connection | +| Screen capture (`image.screen_capture`) | `image` | The Shield's current screen — **opt-in, off by default**, manual/diagnostic only (see [Screen capture](#screen-capture-opt-in-manual)) | ### Now-playing attributes diff --git a/custom_components/nvidia_shield/__init__.py b/custom_components/nvidia_shield/__init__.py index b6f9007..25d8524 100644 --- a/custom_components/nvidia_shield/__init__.py +++ b/custom_components/nvidia_shield/__init__.py @@ -44,7 +44,16 @@ from .helpers import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON] +# t66: IMAGE is listed unconditionally — the platform's async_setup_entry adds +# nothing unless the user opted in, so an install that never turns screen capture +# on pays nothing for the platform existing (and toggling it is an options reload). +PLATFORMS = [ + Platform.MEDIA_PLAYER, + Platform.SENSOR, + Platform.REMOTE, + Platform.BUTTON, + Platform.IMAGE, +] async def async_setup(hass: HomeAssistant, config) -> bool: diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 49dedeb..d6d5771 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -31,6 +31,7 @@ from .const import ( CONF_RECEIVER_ENTITY, CONF_RECEIVER_INPUT, CONF_REMOTE_COMMAND_SCRIPT, + CONF_SCREEN_CAPTURE_ENABLED, CONF_SKIP_PAIRING, DEFAULT_ADB_PORT, DEFAULT_NAME, @@ -1438,6 +1439,13 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload): CONF_ADB_COMMAND_ENABLED, default=bool(options.get(CONF_ADB_COMMAND_ENABLED, False)), ): bool, + # t66: the screen-capture image entity. Default OFF — a capture is a + # full-resolution screen grab, and the standing constraint is that + # screen capture must never drive state. + vol.Optional( + CONF_SCREEN_CAPTURE_ENABLED, + default=bool(options.get(CONF_SCREEN_CAPTURE_ENABLED, False)), + ): bool, vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"], vol.Optional(CONF_POWER_ENTITY): sel["power"], } @@ -1917,6 +1925,7 @@ _ALL_OPTION_FIELDS = frozenset( CONF_ADB_STORE_APP_INFO, CONF_LOGCAT_EVENTS, CONF_ADB_COMMAND_ENABLED, + CONF_SCREEN_CAPTURE_ENABLED, CONF_JELLYFIN_ENTITY, CONF_POWER_ENTITY, CONF_RECEIVER_ENTITY, diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index 8e77e2f..efa2873 100644 --- a/custom_components/nvidia_shield/const.py +++ b/custom_components/nvidia_shield/const.py @@ -263,6 +263,11 @@ 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). diff --git a/custom_components/nvidia_shield/image.py b/custom_components/nvidia_shield/image.py new file mode 100644 index 0000000..b506fa4 --- /dev/null +++ b/custom_components/nvidia_shield/image.py @@ -0,0 +1,280 @@ +"""Screen-capture image entity — an OPT-IN diagnostic surface (t66). + +The user asked for "an image entity of the device screen capture? Can that be +added". This is that entity, and it is the only thing in the integration that +grabs the screen. Three boundaries are deliberate and load-bearing: + +* **It never drives state.** An image entity's state IS its + ``image_last_updated`` timestamp, and nothing here writes to the manager's + store, to the power ladder or to any other entity. A capture cannot make the + media player, the sensors or the power switch claim something the device did + not report. (The user's original constraint: screen capture must never drive + state.) +* **It is opt-in, default OFF** (``CONF_SCREEN_CAPTURE_ENABLED``). With the + option absent ``async_setup_entry`` adds no entity at all. +* **It never polls.** ``ImageEntity`` ships ``_attr_should_poll = False`` ("no + need to poll image entities") and this module keeps it. Nothing runs on a + timer; a capture happens only when something asks for the bytes (the image + proxy serving ``entity_picture``) or when the user calls + ``homeassistant.update_entity``. + +BINARY-SAFE CAPTURE — base64 over the shell, and why not a device file +===================================================================== +``AdbDevice.shell()`` returns ``str``: it decodes the shell channel as text, so +raw PNG bytes cannot travel through it — ``\\x89PNG`` is not UTF-8 and the +undecodable bytes would be mangled on the way out. There are two binary-safe +routes and this module takes the SECOND: + +1. ``screencap -p /sdcard/.png`` then ``AdbDevice.pull()`` — the library's + real binary path (the ADB sync protocol). It needs the raw ``AdbDevice`` + object, which the ADB client owns as a private attribute, and it writes to + the device's shared storage: something must delete that file afterwards, and + a crash between write and delete leaves a full-resolution screenshot behind + on the user's device. +2. ``screencap -p | base64`` then ``base64.b64decode`` locally. Base64 output is + pure ASCII, so the shell's text decoding cannot corrupt it, and there is no + device-side file at all — nothing to clean up, nothing to leak. + +Route 2 costs ~33% more bytes on the socket than route 1, and one claim is +INFERRED rather than measured: no Shield was available here, so how the +persistent ADB socket handles a few hundred kilobytes of base64 in one +``shell()`` call is untested on hardware (the README says so). It is still the +right trade: it uses the PUBLIC seam the watchdog's socket already offers +(``manager.adb_poller.shell``), leaves no artifact on the device, and its +correctness on the wire is provable here with a real PNG fixture. + +One capture costs exactly ONE shell, whatever the caller does: concurrent +requests share a lock, and a successful capture is reused for +``CAPTURE_REUSE_SECONDS`` so a dashboard repainting cannot hammer the device. +""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import logging +import time + +from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import CONF_SCREEN_CAPTURE_ENABLED, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +#: ``screencap -p`` writes PNG to stdout; ``base64`` makes it ASCII, which is the +#: whole trick — see the module docstring. +SCREEN_CAPTURE_COMMAND = "screencap -p | base64" + +#: The PNG file signature. Nothing but real PNG bytes ever leaves this module. +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + +#: A successful capture is reused for this long. Several image fetches in a row +#: (a dashboard reload, two clients, a stream preview) must not become one screen +#: grab each — "cannot hammer the device" is a property of the code, not a hope. +CAPTURE_REUSE_SECONDS = 5.0 + +#: The reason the entity is (un)available, published as ``capture_status``. +STATUS_OK = "ok" +STATUS_ADB_DISABLED = "adb_disabled" +STATUS_ADB_DISCONNECTED = "adb_disconnected" +STATUS_CAPTURE_FAILED = "capture_failed" + +#: The attribute HA reads beside the state — declared unrecorded (G12). +ATTR_CAPTURE_STATUS = "capture_status" + + +def decode_screen_capture(output: str | None) -> bytes | None: + """Decode one ``screencap -p | base64`` answer into PNG bytes, or None. + + Pure on purpose, so the one part that can corrupt an image is testable + without a device: the answer is base64 with ``base64``'s line wrapping (the + whitespace is not part of the payload), the decoded bytes MUST carry the PNG + signature, and every other outcome — empty output, a shell that never + answered, a device that printed an error line instead — is a failure that + returns None rather than a corrupt or zero-byte image. + """ + if not output: + return None + try: + data = base64.b64decode("".join(output.split()), validate=True) + except (binascii.Error, ValueError): + return None + if not data.startswith(PNG_SIGNATURE): + return None + # The signature alone is not a PNG: the spec puts IHDR as the FIRST chunk, + # immediately after it (length 13, type "IHDR"). A shell that returned the + # signature plus junk — or a device that answered with a header in another + # format — fails here. What CANNOT be detected locally is a truncation AFTER + # the header: those bytes are still a well-formed PNG prefix, and the + # consumer (HA's image proxy, then the browser) is what rejects them. + if data[12:16] != b"IHDR": + return None + return data + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add the screen-capture entity — only when the user opted in (t66).""" + if not entry.options.get(CONF_SCREEN_CAPTURE_ENABLED, False): + return # default OFF: no entity, no capture, no cost + + manager = getattr(entry, "runtime_data", None) + if manager is None: + _LOGGER.debug("Screen capture skipped: no runtime manager on the entry") + return + + async_add_entities([NvidiaShieldScreenImage(hass, entry)]) + + +class NvidiaShieldScreenImage(ImageEntity): + """The Shield's current screen as a PNG — manual, diagnostic, never a source. + + A capture is fetched on demand and nothing else: the entity publishes no + command, writes no state source and is not polled. Its state is the + timestamp of the last successful capture, and its ``capture_status`` + attribute names why it cannot serve an image when it cannot. + """ + + _attr_has_entity_name = True + _attr_translation_key = "screen_capture" + _attr_content_type = "image/png" + # HA's own default for image entities, kept explicitly: this entity must + # never turn into a timer that grabs the screen every scan interval. + _attr_should_poll = False + # G12: a poll-adjacent value must not bloat the recorder database, and the + # image bytes are never part of the state at all. + _unrecorded_attributes = frozenset({ATTR_CAPTURE_STATUS}) + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialise the entity over the entry's runtime manager. + + The two-argument shape every platform in this integration uses: the + manager travels on ``entry.runtime_data`` (t40's wiring), so the real-HA + entity matrix can build this class exactly like the others. + """ + super().__init__(hass) + # ImageEntity.__init__ builds HA's shared httpx client but does NOT set + # ``hass`` (the platform does that in production). The rest of this + # integration's entities assign it themselves, and without it HA's own + # state write raises "Attribute hass is None" — measured in the real-HA + # matrix, which builds entities the same way the tests do. + self.hass = hass + self._entry = entry + self._png: bytes | None = None + self._captured_at: float = 0.0 + # One shell at a time, however many callers ask for the image. + self._lock = asyncio.Lock() + poller = self._adb_poller() + self._status = ( + STATUS_ADB_DISABLED + if poller is None + else STATUS_OK + if poller.connected + else STATUS_ADB_DISCONNECTED + ) + self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_screen_capture" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="NVIDIA", + model="Shield TV", + ) + + # -- availability ------------------------------------------------------ + + def _adb_poller(self): + """The ADB client this entry owns, or None when ADB is off/not built.""" + manager = getattr(self._entry, "runtime_data", None) + if manager is None: + return None + return getattr(manager, "adb_poller", None) + + @property + def available(self) -> bool: + """True only while a capture can actually be served. + + Unavailable is the honest answer for "ADB is off", "ADB is down" and "the + last capture failed"; the alternative — being available and handing HA an + empty image — is exactly the blank screenshot this must never produce. + """ + return self._status == STATUS_OK + @property + def capture_status(self) -> str: + """Why the entity is (un)available — the same value as the attribute.""" + return self._status + + @property + def extra_state_attributes(self) -> dict: + """The reason behind the availability, as a PROPERTY (HA reads attrs).""" + return {ATTR_CAPTURE_STATUS: self._status} + + # -- the capture path -------------------------------------------------- + + async def async_image(self) -> bytes | None: + """Return the PNG bytes, capturing on demand (the image proxy seam).""" + return await self._async_capture() + + async def async_update(self) -> None: + """Explicit refresh (``homeassistant.update_entity``) — skip the reuse. + + This is also how a failed capture is retried: a failure makes the entity + unavailable on purpose, and an explicit refresh is the user's way back + without waiting for the next dashboard fetch. + """ + await self._async_capture(force=True) + + async def _async_capture(self, *, force: bool = False) -> bytes | None: + """One screen grab, shared by everyone who asks within the reuse window.""" + async with self._lock: + if ( + not force + and self._png is not None + and time.monotonic() - self._captured_at < CAPTURE_REUSE_SECONDS + ): + return self._png + + poller = self._adb_poller() + if poller is None: + return self._fail(STATUS_ADB_DISABLED) + if not poller.connected: + return self._fail(STATUS_ADB_DISCONNECTED) + try: + output = await poller.shell(SCREEN_CAPTURE_COMMAND) + except Exception as err: # the transport is remote; it may raise anything + # ``AdbClientBase.shell`` already turns its own failures into + # None, so this is defence in depth — a raised error must still + # end as "unavailable", never as a blank image in HA. + _LOGGER.warning("Screen capture failed on %s: %s", self._entry.title, err) + return self._fail(STATUS_CAPTURE_FAILED) + data = decode_screen_capture(output) + if data is None: + # Never a blank or zero-byte image: no answer, a truncated one, + # an error line or a non-PNG all land here. + _LOGGER.warning( + "Screen capture from %s did not return a PNG (%d bytes of " + "shell output)", + self._entry.title, + len(output or ""), + ) + return self._fail(STATUS_CAPTURE_FAILED) + + self._png = data + self._captured_at = time.monotonic() + self._status = STATUS_OK + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() + return data + + def _fail(self, status: str) -> None: + """Record why no image can be served and stay unavailable.""" + self._status = status + return None diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index afde904..a2e91fe 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -206,6 +206,7 @@ "protocol_enabled": "Add the Nvidia Remote Protocol", "logcat_events": "Watch app launches over logcat", "adb_command_enabled": "Allow the adb_command action", + "screen_capture_enabled": "Add a screen capture image entity", "jellyfin_entity": "Jellyfin media player", "power_entity": "Power switch" }, @@ -217,6 +218,7 @@ "protocol_enabled": "The all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs the one-time PIN pairing from setup.", "logcat_events": "Uses a second ADB stream to notice app launches the moment they happen. Leave off unless you want the faster app switch.", "adb_command_enabled": "Exposes the adb_command action for this entry's media player. Off by default; it runs arbitrary shell commands on the Shield.", + "screen_capture_enabled": "Adds an image entity showing the Shield's screen on demand. Off by default: a capture is a full-resolution screen grab, it is a manual/diagnostic surface, and it never drives state.", "jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.", "power_entity": "A smart plug switch, if you want reliable on/off detection." } @@ -380,6 +382,11 @@ "installed_apps": { "name": "Installed apps" } + }, + "image": { + "screen_capture": { + "name": "Screen capture" + } } } } diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index afde904..a2e91fe 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -206,6 +206,7 @@ "protocol_enabled": "Add the Nvidia Remote Protocol", "logcat_events": "Watch app launches over logcat", "adb_command_enabled": "Allow the adb_command action", + "screen_capture_enabled": "Add a screen capture image entity", "jellyfin_entity": "Jellyfin media player", "power_entity": "Power switch" }, @@ -217,6 +218,7 @@ "protocol_enabled": "The all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs the one-time PIN pairing from setup.", "logcat_events": "Uses a second ADB stream to notice app launches the moment they happen. Leave off unless you want the faster app switch.", "adb_command_enabled": "Exposes the adb_command action for this entry's media player. Off by default; it runs arbitrary shell commands on the Shield.", + "screen_capture_enabled": "Adds an image entity showing the Shield's screen on demand. Off by default: a capture is a full-resolution screen grab, it is a manual/diagnostic surface, and it never drives state.", "jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.", "power_entity": "A smart plug switch, if you want reliable on/off detection." } @@ -380,6 +382,11 @@ "installed_apps": { "name": "Installed apps" } + }, + "image": { + "screen_capture": { + "name": "Screen capture" + } } } } diff --git a/tests/entity_stublib.py b/tests/entity_stublib.py index 54b3b7d..25df1d6 100644 --- a/tests/entity_stublib.py +++ b/tests/entity_stublib.py @@ -7,6 +7,7 @@ their entity module through the synthetic ``nsh`` package AFTER calling """ import asyncio +import datetime import enum import sys import types @@ -25,6 +26,7 @@ ENTITIES_STUB_NAMES = ( "homeassistant.components.media_player", "homeassistant.components.remote", "homeassistant.components.button", + "homeassistant.components.image", "homeassistant.helpers", "homeassistant.helpers.entity_registry", "homeassistant.helpers.device_registry", @@ -33,12 +35,15 @@ ENTITIES_STUB_NAMES = ( "homeassistant.helpers.redact", "homeassistant.helpers.entity", "homeassistant.helpers.entity_platform", + "homeassistant.util", + "homeassistant.util.dt", "nsh.helpers", "nsh.power", "nsh.jellyfin", "nsh.media_player", "nsh.diagnostics", "nsh.remote", + "nsh.image", ) @@ -431,6 +436,7 @@ def install() -> None: SENSOR="sensor", REMOTE="remote", BUTTON="button", + IMAGE="image", ) sys.modules["homeassistant.const"] = ha_const @@ -463,6 +469,93 @@ def install() -> None: sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app") sys.modules["homeassistant.components.sensor"] = sensor_platform + # t66: homeassistant.components.image.ImageEntity — the slice the screen + # capture entity uses, carrying the REAL contract that matters here: the + # attributes HA reads (``content_type``, ``image_last_updated``), the real + # ``__init__(hass, verify_ssl=False)`` signature, ``async_image`` as the + # bytes seam, state writing as a no-op (the harness writes no states), and + # HA 2026.10's own ``_attr_should_poll = False`` ("No need to poll image + # entities"). A subclass that forgets its base ``__init__`` fails here too. + image_platform = types.ModuleType("homeassistant.components.image") + + class _EntityFace: + """The Entity surface HA reads, backed by the ``_attr_*`` convention. + + The real ImageEntity inherits all of this from Entity; the stub carries + the properties so a test can READ what the entity publishes (naming, + device, polling, availability) without a state machine. + """ + + _attr_available = True + _attr_should_poll = True + _attr_translation_key = None + _attr_unique_id = None + _attr_device_info = None + _attr_extra_state_attributes = None + + def async_write_ha_state(self) -> None: + return None + + @property + def available(self) -> bool: + return self._attr_available + + @property + def should_poll(self) -> bool: + return self._attr_should_poll + + @property + def translation_key(self): + return self._attr_translation_key + + @property + def unique_id(self): + return self._attr_unique_id + + @property + def device_info(self): + return self._attr_device_info + + @property + def extra_state_attributes(self): + return self._attr_extra_state_attributes + + class ImageEntity(_EntityFace): + _attr_content_type = "image/jpeg" + _attr_image_last_updated = None + _attr_should_poll = False + + def __init__(self, hass, verify_ssl: bool = False) -> None: + self.hass = hass + + @property + def content_type(self) -> str: + return self._attr_content_type + + @property + def image_last_updated(self): + return self._attr_image_last_updated + + async def async_image(self) -> bytes | None: + raise NotImplementedError + + image_platform.ImageEntity = ImageEntity + sys.modules["homeassistant.components.image"] = image_platform + + # t66: homeassistant.util.dt — the timestamp helper the image entity stamps + # ``image_last_updated`` with. ``utcnow()`` really is UTC-aware, as in HA. + ha_util = types.ModuleType("homeassistant.util") + ha_util.__path__ = [] + dt_util = types.ModuleType("homeassistant.util.dt") + + def _utcnow(): + return datetime.datetime.now(datetime.UTC) + + dt_util.utcnow = _utcnow + ha_util.dt = dt_util + sys.modules["homeassistant.util"] = ha_util + sys.modules["homeassistant.util.dt"] = dt_util + helpers = types.ModuleType("homeassistant.helpers") entity_registry = types.ModuleType("homeassistant.helpers.entity_registry") @@ -617,6 +710,9 @@ def load_entity_modules() -> dict: # belongs here; tests/test_module_coverage.py sweeps EVERY module, which # is what keeps this list from having to be complete. "button", + # t66: the screen-capture platform is a platform like the others, so it + # loads here too (and the module-coverage sweep already walks every file) + "image", "init", ): # "init" maps the integration __init__.py (t29 G8 async_setup) if name == "init" and "nsh.init" in sys.modules: @@ -633,6 +729,7 @@ def load_entity_modules() -> dict: "media_player", "remote", "diagnostics", + "image", ) } diff --git a/tests/test_config_flow_protocol.py b/tests/test_config_flow_protocol.py index 2aff219..ed96b8c 100644 --- a/tests/test_config_flow_protocol.py +++ b/tests/test_config_flow_protocol.py @@ -211,6 +211,7 @@ def _install_stubs() -> None: SENSOR = "sensor" REMOTE = "remote" BUTTON = "button" + IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS ha_const.Platform = _Platform sys.modules["homeassistant.const"] = ha_const diff --git a/tests/test_config_flow_ux.py b/tests/test_config_flow_ux.py index ead75b7..96579a5 100644 --- a/tests/test_config_flow_ux.py +++ b/tests/test_config_flow_ux.py @@ -47,6 +47,7 @@ from nsh.const import ( CONF_POWER_ENTITY, CONF_PROTOCOL_ENABLED, CONF_REMOTE_COMMAND_SCRIPT, + CONF_SCREEN_CAPTURE_ENABLED, CONF_SKIP_PAIRING, DEFAULT_ADB_PORT, SCRIPT_COMMANDS, @@ -249,7 +250,7 @@ def _install_stubs() -> None: ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop" ha_const.Platform = types.SimpleNamespace( MEDIA_PLAYER="media_player", SENSOR="sensor", REMOTE="remote", - BUTTON="button", + BUTTON="button", IMAGE="image", ) sys.modules["homeassistant.const"] = ha_const @@ -1603,6 +1604,7 @@ OPTIONS_SUBSTEP_FIELDS = { CONF_POWER_ENTITY, CONF_LOGCAT_EVENTS, CONF_ADB_COMMAND_ENABLED, + CONF_SCREEN_CAPTURE_ENABLED, }, "receiver": {"receiver_entity", "receiver_input"}, # t64: the apps table lives in its OWN step now (the Apps sub-screen is a menu @@ -2765,3 +2767,44 @@ class TestSetupMenu: assert flow._host() == "10.0.0.77" assert result["options"].get("receiver_entity") == "switch.soundbar" assert result["options"].get("receiver_input") == "HDMI 1" + + +class TestScreenCaptureOption: + """t66: the screen-capture image entity is opt-in, and OFF is the default.""" + + async def test_the_connection_screen_offers_it_and_defaults_off(self, tmp_path): + """Default OFF at the SCHEMA level — the form the user is shown.""" + flow = _options_flow(tmp_path) + + result = await _options_open(flow, "connection") + + defaults = vol_mirror.defaults_by_key(result["data_schema"]) + assert defaults[CONF_SCREEN_CAPTURE_ENABLED] is False + + async def test_an_untouched_toggle_stays_off(self, tmp_path): + """A save that never renders the screen-capture row must not invent it.""" + flow = _options_flow(tmp_path) + + result = await _options_submit(flow, {CONF_HOST: "10.0.0.9"}, save=True) + + assert result["type"] == "create_entry" + assert not result["data"].get(CONF_SCREEN_CAPTURE_ENABLED) + + async def test_switching_it_on_is_stored(self, tmp_path): + flow = _options_flow(tmp_path) + + result = await _options_submit( + flow, {CONF_SCREEN_CAPTURE_ENABLED: True}, save=True + ) + + assert result["type"] == "create_entry" + assert result["data"][CONF_SCREEN_CAPTURE_ENABLED] is True + + async def test_it_can_be_switched_back_off(self, tmp_path): + flow = _options_flow(tmp_path, {CONF_SCREEN_CAPTURE_ENABLED: True}) + + result = await _options_submit( + flow, {CONF_SCREEN_CAPTURE_ENABLED: False}, save=True + ) + + assert result["data"][CONF_SCREEN_CAPTURE_ENABLED] is False diff --git a/tests/test_ha_api_drift.py b/tests/test_ha_api_drift.py index cb9163e..f8380fc 100644 --- a/tests/test_ha_api_drift.py +++ b/tests/test_ha_api_drift.py @@ -267,6 +267,10 @@ def _real_ha_entity(module_name, class_name, *, options=None, entity_id=None): states=states, data={}, config=SimpleNamespace(components=set()), + # t66: ``ImageEntity.__init__`` builds HA's shared httpx client, which + # registers a shutdown listener on ``hass.bus``. The fake hass carries + # just enough of that surface for the real base class to construct. + bus=SimpleNamespace(async_listen_once=lambda *args, **kwargs: (lambda: None)), ) entry = SimpleNamespace( entry_id="e1", data={}, options=dict(options or {}), title="Shield" @@ -317,6 +321,12 @@ _REAL_HA_MATRIX = ( ), ("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"), ("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"), + ( + "image", + "NvidiaShieldScreenImage", + {"screen_capture_enabled": True}, + "image.shield_screen_capture", + ), ) #: Classified as an entity by the sweep, but an abstract intermediate base HA @@ -362,6 +372,58 @@ def test_the_media_player_extra_attributes_reach_the_state_machine(): assert {"app_id", "app_name"} <= set(attrs), sorted(attrs) +#: The same real 2x1 PNG fixture tests/test_screen_capture_image.py uses (the +#: wire form ``screencap -p | base64`` produces) — a real PNG, not a stand-in. +_SCREEN_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/" + "Af+4BDXgAAAAAElFTkSuQmCC" +) + + +def test_the_screen_capture_entity_serves_png_bytes_under_real_ha(): + """t66: HA's OWN image pieces accept what the capture path produces. + + The stub suite proves the logic; this proves the contract with real HA: the + real ``ImageEntity`` base, HA's ``valid_image_content_type`` (what the image + proxy runs before it will serve bytes), and the real state write — whose + recorded state must be the capture TIMESTAMP and whose attributes must never + carry the image itself (the "never a state source" boundary, measured). + """ + _require_installed_homeassistant() + + import base64 + + from homeassistant.components.image import valid_image_content_type + + class _Poller: + connected = True + + async def shell(self, cmd): + assert cmd == "screencap -p | base64" + return base64.b64encode(base64.b64decode(_SCREEN_PNG_B64)).decode() + + entity, states = _real_ha_entity( + "image", + "NvidiaShieldScreenImage", + options={"screen_capture_enabled": True}, + entity_id="image.shield_screen_capture", + ) + entity._entry.runtime_data = SimpleNamespace(adb_poller=_Poller()) + + data = asyncio.run(entity.async_image()) + + assert isinstance(data, bytes) + assert data.startswith(b"\x89PNG\r\n\x1a\n") + assert valid_image_content_type(entity.content_type) == "image/png" + + written_id, state, attrs = _write_ha_state(entity, states) + + assert written_id == "image.shield_screen_capture" + # HA's image state IS ``image_last_updated`` — the timestamp, never the bytes + assert state == entity.image_last_updated.isoformat() + assert attrs["capture_status"] == "ok" + assert not any(isinstance(value, (bytes, bytearray)) for value in attrs.values()) + def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree(): """A new entity class must join the matrix above, or this test fails. diff --git a/tests/test_screen_capture_image.py b/tests/test_screen_capture_image.py new file mode 100644 index 0000000..ab65e44 --- /dev/null +++ b/tests/test_screen_capture_image.py @@ -0,0 +1,383 @@ +"""t66: the opt-in screen-capture IMAGE entity. + +WHAT IS PROVEN HERE, AND WHAT IS NOT +------------------------------------ +Proven: the bytes that reach Home Assistant are REAL PNG bytes — ``PNG_B64`` +below is a real 2x1 PNG, base64'd on the wire exactly the way +``screencap -p | base64`` sends it — the entity hands HA ``bytes`` and never a +``str``, a disconnected link or a bad answer makes the entity UNAVAILABLE +instead of serving a blank/zero-byte image, one capture is coalesced and reused +so a dashboard cannot hammer the device, and the option defaults OFF so no +entity exists until the user opts in. + +NOT proven: anything about a real Shield. No device was available in this +environment, so ``screencap``'s flags, the device's base64 and — the important +one — how the persistent ADB socket copes with a few hundred kilobytes of +base64 in a single ``shell()`` call are INFERRED, not measured. ``FakeAdbPoller`` +answers the way a working Shield should. +""" + +import asyncio +import base64 +import json +import sys +from datetime import datetime +from types import SimpleNamespace + +import entity_stublib +import pytest +from conftest import _SHIELD_DIR +from entity_stublib import install, load_entity_modules + +#: A REAL 2x1 PNG (one red pixel, one green pixel) — 72 bytes, base64. It is the +#: wire form the device sends, so the fixture exercises the real decode path +#: rather than an invented one. +PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/" + "Af+4BDXgAAAAAElFTkSuQmCC" +) +PNG_BYTES = base64.b64decode(PNG_B64) +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def wrapped(text: str, width: int = 76) -> str: + """``base64``'s own line wrapping — the device wraps, and that is not corruption.""" + return "\n".join(text[i : i + width] for i in range(0, len(text), width)) + "\n" + + +@pytest.fixture(autouse=True) +def scoped_entity_stubs(): + """Container for the HA stubs + loaded entity modules (t7 discipline).""" + snapshot = entity_stublib.snapshot_modules() + install() + yield load_entity_modules() + entity_stublib.restore_modules(snapshot) + + +@pytest.fixture(name="modules") +def load_modules_fixture(): + return load_entity_modules() + + +class FakeAdbPoller: + """The ADB seam the entity is allowed to use: ``connected`` + ``shell``.""" + + def __init__(self, answer=None, *, connected=True, delay=0.0): + self.connected = connected + self.answer = answer + self.delay = delay + self.calls: list[str] = [] + + async def shell(self, cmd: str): + self.calls.append(cmd) + if self.delay: + await asyncio.sleep(self.delay) + if isinstance(self.answer, Exception): + raise self.answer + if callable(self.answer): + return self.answer(cmd) + return self.answer + + +class StrictManager: + """Only ``adb_poller`` may be touched — nothing else exists on purpose. + + "Never a state source" is a claim about what the capture path reads and + writes: it reads the ADB poller for a screen grab and writes nothing at all. + A manager that raises on every other attribute turns a future reach-in (a + store write, a state push) into a failing test instead of a quiet coupling + between screen capture and the state pipeline. + """ + + def __init__(self, poller): + self.adb_poller = poller + + def __getattr__(self, name): + raise AssertionError(f"the screen-capture path touched manager.{name}") + + +def make_entry(options=None, *, entry_id="e1", title="Shield"): + return SimpleNamespace( + entry_id=entry_id, title=title, options=dict(options or {}), runtime_data=None + ) + + +def make_entity(modules, poller, options=None): + """The entity over a fake manager on the entry, plus the entry it belongs to.""" + entry = make_entry(options or {"screen_capture_enabled": True}) + if poller is not None: + entry.runtime_data = StrictManager(poller) + entity = modules["image"].NvidiaShieldScreenImage(SimpleNamespace(), entry) + return entity, entry + + +def fake_clock(modules, monkeypatch, start=1000.0): + """Freeze ``time.monotonic`` for the capture-window tests. Returns the cell.""" + now = [start] + monkeypatch.setattr( + modules["image"], "time", SimpleNamespace(monotonic=lambda: now[0]) + ) + return now + + +# ── the decode path ────────────────────────────────────────────────────────── + + +class TestScreenCaptureDecode: + """``decode_screen_capture`` — the one place an image could be corrupted.""" + + def test_a_real_png_survives_the_wire_format(self, modules): + decode = modules["image"].decode_screen_capture + + data = decode(wrapped(PNG_B64)) + + assert data == PNG_BYTES # byte-for-byte, wrapping and all + assert data.startswith(PNG_SIGNATURE) + assert isinstance(data, bytes) # not a str: this is base64-decoded + + @pytest.mark.parametrize( + "answer", + [ + None, # the transport never answered (AdbClientBase.shell's failure) + "", # an empty answer is not an image + "not base64 at all !!", + base64.b64encode(b"hello world, definitely not a png").decode(), + # a signature and nothing else: not a PNG (the IHDR chunk is missing) + base64.b64encode(PNG_SIGNATURE + b"\x00" * 8).decode(), + ], + ) + def test_everything_that_is_not_a_png_is_rejected(self, modules, answer): + assert modules["image"].decode_screen_capture(answer) is None + + def test_a_device_error_line_is_not_an_image(self, modules): + polluted = "error: no display\n" + wrapped(PNG_B64) + + assert modules["image"].decode_screen_capture(polluted) is None + + +# ── the entity ─────────────────────────────────────────────────────────────── + + +class TestScreenCaptureEntity: + async def test_the_entity_serves_real_png_bytes_and_never_a_str(self, modules): + poller = FakeAdbPoller(wrapped(PNG_B64)) + entity, _entry = make_entity(modules, poller) + + data = await entity.async_image() + + assert data == PNG_BYTES + assert data.startswith(PNG_SIGNATURE) + assert not isinstance(data, str) + assert entity.available is True + assert entity.capture_status == "ok" + assert entity.extra_state_attributes["capture_status"] == "ok" + + async def test_the_command_is_the_base64_route_and_needs_no_device_file(self, modules): + """Route 2 of the module docstring, and the reason it was chosen.""" + poller = FakeAdbPoller(wrapped(PNG_B64)) + entity, _entry = make_entity(modules, poller) + + await entity.async_image() + + assert poller.calls == ["screencap -p | base64"] + # No device-side artifact exists, so there is nothing to clean up... + assert "/sdcard" not in poller.calls[0] + assert "rm " not in poller.calls[0] + + async def test_a_disconnected_link_is_unavailable_and_is_never_asked(self, modules): + poller = FakeAdbPoller(wrapped(PNG_B64), connected=False) + entity, _entry = make_entity(modules, poller) + + assert entity.available is False + assert entity.capture_status == "adb_disconnected" + assert await entity.async_image() is None + assert poller.calls == [] # a dead link is not poked for a screenshot + + async def test_adb_disabled_has_its_own_reason(self, modules): + entry = make_entry({"screen_capture_enabled": True}) + manager = SimpleNamespace(adb_poller=None) + entry.runtime_data = manager + entity = modules["image"].NvidiaShieldScreenImage(SimpleNamespace(), entry) + + assert entity.available is False + assert entity.capture_status == "adb_disabled" + assert await entity.async_image() is None + + async def test_an_empty_answer_is_unavailable_not_a_blank_image(self, modules): + poller = FakeAdbPoller(None) + entity, _entry = make_entity(modules, poller) + + assert await entity.async_image() is None + assert entity.available is False + assert entity.capture_status == "capture_failed" + + async def test_a_transport_that_raises_is_a_failure_not_a_crash(self, modules): + poller = FakeAdbPoller(RuntimeError("socket closed under us")) + entity, _entry = make_entity(modules, poller) + + assert await entity.async_image() is None + assert entity.available is False + assert entity.capture_status == "capture_failed" + + async def test_a_failed_capture_recovers_on_the_next_request(self, modules): + poller = FakeAdbPoller(None) + entity, _entry = make_entity(modules, poller) + + assert await entity.async_image() is None + assert entity.available is False + + poller.answer = wrapped(PNG_B64) # the device came back + + assert await entity.async_image() == PNG_BYTES + assert entity.available is True + assert entity.capture_status == "ok" + + async def test_one_capture_is_reused_inside_the_window(self, modules, monkeypatch): + """A dashboard repaint is not a screen grab per repaint.""" + now = fake_clock(modules, monkeypatch) + poller = FakeAdbPoller(wrapped(PNG_B64)) + entity, _entry = make_entity(modules, poller) + + first = await entity.async_image() + now[0] += modules["image"].CAPTURE_REUSE_SECONDS - 1.0 + second = await entity.async_image() + + assert first == second == PNG_BYTES + assert len(poller.calls) == 1 # one shell, two image requests + + async def test_the_window_expires_so_a_later_view_refreshes(self, modules, monkeypatch): + now = fake_clock(modules, monkeypatch) + poller = FakeAdbPoller(wrapped(PNG_B64)) + entity, _entry = make_entity(modules, poller) + + await entity.async_image() + now[0] += modules["image"].CAPTURE_REUSE_SECONDS + 1.0 + await entity.async_image() + + assert len(poller.calls) == 2 + + async def test_an_explicit_refresh_skips_the_reuse_window(self, modules, monkeypatch): + """``homeassistant.update_entity`` means "grab it now".""" + fake_clock(modules, monkeypatch) + poller = FakeAdbPoller(wrapped(PNG_B64)) + entity, _entry = make_entity(modules, poller) + + await entity.async_image() + await entity.async_update() + + assert len(poller.calls) == 2 + + async def test_concurrent_requests_share_one_capture(self, modules): + """Parallel image fetches must not leak ADB shells.""" + poller = FakeAdbPoller(wrapped(PNG_B64), delay=0.01) + entity, _entry = make_entity(modules, poller) + + results = await asyncio.gather( + entity.async_image(), entity.async_image(), entity.async_image() + ) + + assert list(results) == [PNG_BYTES, PNG_BYTES, PNG_BYTES] + assert len(poller.calls) == 1 + + async def test_capture_reads_the_adb_poller_and_drives_nothing(self, modules): + """The strict manager raises if the capture path touches anything else.""" + poller = FakeAdbPoller(wrapped(PNG_B64)) + entity, _entry = make_entity(modules, poller) # StrictManager + + assert await entity.async_image() == PNG_BYTES + + # ...and the state HA would record is the timestamp, never the bytes + assert entity.image_last_updated is not None + assert isinstance(entity.image_last_updated, datetime) + assert entity.image_last_updated.tzinfo is not None + + async def test_image_last_updated_is_stamped_only_by_a_success(self, modules, monkeypatch): + fake_clock(modules, monkeypatch) + poller = FakeAdbPoller(None) + entity, _entry = make_entity(modules, poller) + + assert entity.image_last_updated is None # nothing captured yet + + assert await entity.async_image() is None + assert entity.image_last_updated is None # a failure stamps nothing + + poller.answer = wrapped(PNG_B64) + await entity.async_image() + stamped = entity.image_last_updated + + assert stamped is not None + poller.answer = None # a later failure must not move it + await entity.async_image() + assert entity.image_last_updated is stamped + + def test_entity_hygiene(self, modules): + """Naming, content type, recorder hygiene, and the no-poll rule.""" + poller = FakeAdbPoller(wrapped(PNG_B64)) + entity, entry = make_entity(modules, poller) + cls = type(entity) + + assert cls._attr_has_entity_name is True + assert entity.translation_key == "screen_capture" + assert entity.content_type == "image/png" + # HA polls image entities never by default; this one must not either + assert cls._attr_should_poll is False + assert entity.should_poll is False + assert entity.unique_id == f"nvidia_shield_{entry.entry_id}_screen_capture" + assert entity.device_info["identifiers"] == {("nvidia_shield", entry.entry_id)} + # the status changes with a capture, so it stays out of the database, and + # no attribute ever carries the image bytes + assert "capture_status" in cls._unrecorded_attributes + for value in entity.extra_state_attributes.values(): + assert not isinstance(value, (bytes, bytearray)) + + def test_the_platform_is_forwarded(self, modules): + """The new platform is in the entry's list, and the manifest still parses.""" + platforms = [str(p) for p in sys.modules["nsh.init"].PLATFORMS] + + assert "image" in platforms + assert platforms.count("image") == 1 + + manifest = json.loads( + (_SHIELD_DIR / "manifest.json").read_text(encoding="utf-8") + ) + # HA discovers platforms from the forwarded list; a "platforms" key in the + # manifest is not a thing (and adding one would be a silent no-op). + assert "platforms" not in manifest + assert "adb-shell>=0.4.0" in manifest["requirements"] + + +# ── opt-in ─────────────────────────────────────────────────────────────────── + + +class TestScreenCaptureOptIn: + """Default OFF: no option, no entity, no cost.""" + + async def _setup(self, modules, options): + added: list = [] + entry = make_entry(options) + entry.runtime_data = StrictManager(FakeAdbPoller(wrapped(PNG_B64))) + await modules["image"].async_setup_entry(SimpleNamespace(), entry, added.extend) + return added + + async def test_the_option_defaults_off_and_creates_no_entity(self, modules): + assert await self._setup(modules, {}) == [] + + assert sys.modules["nsh.const"].CONF_SCREEN_CAPTURE_ENABLED == ( + "screen_capture_enabled" + ) + assert await self._setup(modules, {"screen_capture_enabled": False}) == [] + + async def test_switching_it_on_creates_exactly_one_entity(self, modules): + added = await self._setup(modules, {"screen_capture_enabled": True}) + + assert len(added) == 1 + assert type(added[0]).__name__ == "NvidiaShieldScreenImage" + + async def test_an_entry_without_a_manager_is_skipped_not_crashed(self, modules): + added: list = [] + entry = make_entry({"screen_capture_enabled": True}) + entry.runtime_data = None + + await modules["image"].async_setup_entry(SimpleNamespace(), entry, added.extend) + + assert added == [] diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index aa72905..3e58bf4 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -234,6 +234,7 @@ def _install_stubs() -> None: SENSOR = "sensor" REMOTE = "remote" BUTTON = "button" + IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS ha_const.Platform = _Platform sys.modules["homeassistant.const"] = ha_const