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.
This commit is contained in:
James Mackay committed 2026-10-12 10:28:32 +01:00
1 parent a1ce28996e
commit f29f62ddae
13 files changed
+954 -3

No files matched your search

+48 -1
View File
@@ -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 setup and unregistered when the last participating entity unloads. It
rejects (logs a warning, sends nothing) when the ADB connection is down. 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.<entry>_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 ## Replacing androidtv / androidtv_remote + the glue
This fork replaces the old `androidtv` media player, the `androidtv_remote` 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 | | 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 | | **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) | | **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 | | **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 | | 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) | | 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 | | 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 ### Now-playing attributes
+10 -1
View File
@@ -44,7 +44,16 @@ from .helpers import (
_LOGGER = logging.getLogger(__name__) _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: async def async_setup(hass: HomeAssistant, config) -> bool:
@@ -31,6 +31,7 @@ from .const import (
CONF_RECEIVER_ENTITY, CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT, CONF_RECEIVER_INPUT,
CONF_REMOTE_COMMAND_SCRIPT, CONF_REMOTE_COMMAND_SCRIPT,
CONF_SCREEN_CAPTURE_ENABLED,
CONF_SKIP_PAIRING, CONF_SKIP_PAIRING,
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
DEFAULT_NAME, DEFAULT_NAME,
@@ -1438,6 +1439,13 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
CONF_ADB_COMMAND_ENABLED, CONF_ADB_COMMAND_ENABLED,
default=bool(options.get(CONF_ADB_COMMAND_ENABLED, False)), default=bool(options.get(CONF_ADB_COMMAND_ENABLED, False)),
): bool, ): 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_JELLYFIN_ENTITY): sel["media_player"],
vol.Optional(CONF_POWER_ENTITY): sel["power"], vol.Optional(CONF_POWER_ENTITY): sel["power"],
} }
@@ -1917,6 +1925,7 @@ _ALL_OPTION_FIELDS = frozenset(
CONF_ADB_STORE_APP_INFO, CONF_ADB_STORE_APP_INFO,
CONF_LOGCAT_EVENTS, CONF_LOGCAT_EVENTS,
CONF_ADB_COMMAND_ENABLED, CONF_ADB_COMMAND_ENABLED,
CONF_SCREEN_CAPTURE_ENABLED,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY, CONF_RECEIVER_ENTITY,
+5
View File
@@ -263,6 +263,11 @@ SCRIPT_VAR_NUM_REPEATS = "num_repeats"
SCRIPT_VAR_DELAY_SECS = "delay_secs" SCRIPT_VAR_DELAY_SECS = "delay_secs"
SCRIPT_VAR_HOLD_SECS = "hold_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: def remote_command_script(options) -> str | None:
"""The script configured for remote.send_command, or None (t47). """The script configured for remote.send_command, or None (t47).
+280
View File
@@ -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/<name>.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
@@ -206,6 +206,7 @@
"protocol_enabled": "Add the Nvidia Remote Protocol", "protocol_enabled": "Add the Nvidia Remote Protocol",
"logcat_events": "Watch app launches over logcat", "logcat_events": "Watch app launches over logcat",
"adb_command_enabled": "Allow the adb_command action", "adb_command_enabled": "Allow the adb_command action",
"screen_capture_enabled": "Add a screen capture image entity",
"jellyfin_entity": "Jellyfin media player", "jellyfin_entity": "Jellyfin media player",
"power_entity": "Power switch" "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.", "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.", "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.", "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.", "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." "power_entity": "A smart plug switch, if you want reliable on/off detection."
} }
@@ -380,6 +382,11 @@
"installed_apps": { "installed_apps": {
"name": "Installed apps" "name": "Installed apps"
} }
},
"image": {
"screen_capture": {
"name": "Screen capture"
}
} }
} }
} }
@@ -206,6 +206,7 @@
"protocol_enabled": "Add the Nvidia Remote Protocol", "protocol_enabled": "Add the Nvidia Remote Protocol",
"logcat_events": "Watch app launches over logcat", "logcat_events": "Watch app launches over logcat",
"adb_command_enabled": "Allow the adb_command action", "adb_command_enabled": "Allow the adb_command action",
"screen_capture_enabled": "Add a screen capture image entity",
"jellyfin_entity": "Jellyfin media player", "jellyfin_entity": "Jellyfin media player",
"power_entity": "Power switch" "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.", "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.", "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.", "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.", "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." "power_entity": "A smart plug switch, if you want reliable on/off detection."
} }
@@ -380,6 +382,11 @@
"installed_apps": { "installed_apps": {
"name": "Installed apps" "name": "Installed apps"
} }
},
"image": {
"screen_capture": {
"name": "Screen capture"
}
} }
} }
} }
+97
View File
@@ -7,6 +7,7 @@ their entity module through the synthetic ``nsh`` package AFTER calling
""" """
import asyncio import asyncio
import datetime
import enum import enum
import sys import sys
import types import types
@@ -25,6 +26,7 @@ ENTITIES_STUB_NAMES = (
"homeassistant.components.media_player", "homeassistant.components.media_player",
"homeassistant.components.remote", "homeassistant.components.remote",
"homeassistant.components.button", "homeassistant.components.button",
"homeassistant.components.image",
"homeassistant.helpers", "homeassistant.helpers",
"homeassistant.helpers.entity_registry", "homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry", "homeassistant.helpers.device_registry",
@@ -33,12 +35,15 @@ ENTITIES_STUB_NAMES = (
"homeassistant.helpers.redact", "homeassistant.helpers.redact",
"homeassistant.helpers.entity", "homeassistant.helpers.entity",
"homeassistant.helpers.entity_platform", "homeassistant.helpers.entity_platform",
"homeassistant.util",
"homeassistant.util.dt",
"nsh.helpers", "nsh.helpers",
"nsh.power", "nsh.power",
"nsh.jellyfin", "nsh.jellyfin",
"nsh.media_player", "nsh.media_player",
"nsh.diagnostics", "nsh.diagnostics",
"nsh.remote", "nsh.remote",
"nsh.image",
) )
@@ -431,6 +436,7 @@ def install() -> None:
SENSOR="sensor", SENSOR="sensor",
REMOTE="remote", REMOTE="remote",
BUTTON="button", BUTTON="button",
IMAGE="image",
) )
sys.modules["homeassistant.const"] = ha_const sys.modules["homeassistant.const"] = ha_const
@@ -463,6 +469,93 @@ def install() -> None:
sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app") sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app")
sys.modules["homeassistant.components.sensor"] = sensor_platform 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") helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry") 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 # belongs here; tests/test_module_coverage.py sweeps EVERY module, which
# is what keeps this list from having to be complete. # is what keeps this list from having to be complete.
"button", "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",
): # "init" maps the integration __init__.py (t29 G8 async_setup) ): # "init" maps the integration __init__.py (t29 G8 async_setup)
if name == "init" and "nsh.init" in sys.modules: if name == "init" and "nsh.init" in sys.modules:
@@ -633,6 +729,7 @@ def load_entity_modules() -> dict:
"media_player", "media_player",
"remote", "remote",
"diagnostics", "diagnostics",
"image",
) )
} }
+1
View File
@@ -211,6 +211,7 @@ def _install_stubs() -> None:
SENSOR = "sensor" SENSOR = "sensor"
REMOTE = "remote" REMOTE = "remote"
BUTTON = "button" BUTTON = "button"
IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS
ha_const.Platform = _Platform ha_const.Platform = _Platform
sys.modules["homeassistant.const"] = ha_const sys.modules["homeassistant.const"] = ha_const
+44 -1
View File
@@ -47,6 +47,7 @@ from nsh.const import (
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_ENABLED,
CONF_REMOTE_COMMAND_SCRIPT, CONF_REMOTE_COMMAND_SCRIPT,
CONF_SCREEN_CAPTURE_ENABLED,
CONF_SKIP_PAIRING, CONF_SKIP_PAIRING,
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
SCRIPT_COMMANDS, SCRIPT_COMMANDS,
@@ -249,7 +250,7 @@ def _install_stubs() -> None:
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop" ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
ha_const.Platform = types.SimpleNamespace( ha_const.Platform = types.SimpleNamespace(
MEDIA_PLAYER="media_player", SENSOR="sensor", REMOTE="remote", MEDIA_PLAYER="media_player", SENSOR="sensor", REMOTE="remote",
BUTTON="button", BUTTON="button", IMAGE="image",
) )
sys.modules["homeassistant.const"] = ha_const sys.modules["homeassistant.const"] = ha_const
@@ -1603,6 +1604,7 @@ OPTIONS_SUBSTEP_FIELDS = {
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_LOGCAT_EVENTS, CONF_LOGCAT_EVENTS,
CONF_ADB_COMMAND_ENABLED, CONF_ADB_COMMAND_ENABLED,
CONF_SCREEN_CAPTURE_ENABLED,
}, },
"receiver": {"receiver_entity", "receiver_input"}, "receiver": {"receiver_entity", "receiver_input"},
# t64: the apps table lives in its OWN step now (the Apps sub-screen is a menu # 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 flow._host() == "10.0.0.77"
assert result["options"].get("receiver_entity") == "switch.soundbar" assert result["options"].get("receiver_entity") == "switch.soundbar"
assert result["options"].get("receiver_input") == "HDMI 1" 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
+62
View File
@@ -267,6 +267,10 @@ def _real_ha_entity(module_name, class_name, *, options=None, entity_id=None):
states=states, states=states,
data={}, data={},
config=SimpleNamespace(components=set()), 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 = SimpleNamespace(
entry_id="e1", data={}, options=dict(options or {}), title="Shield" entry_id="e1", data={}, options=dict(options or {}), title="Shield"
@@ -317,6 +321,12 @@ _REAL_HA_MATRIX = (
), ),
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"), ("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"), ("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 #: 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) 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(): 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. """A new entity class must join the matrix above, or this test fails.
+383
View File
@@ -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 == []
+1
View File
@@ -234,6 +234,7 @@ def _install_stubs() -> None:
SENSOR = "sensor" SENSOR = "sensor"
REMOTE = "remote" REMOTE = "remote"
BUTTON = "button" BUTTON = "button"
IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS
ha_const.Platform = _Platform ha_const.Platform = _Platform
sys.modules["homeassistant.const"] = ha_const sys.modules["homeassistant.const"] = ha_const