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:
1 parent
a1ce28996e
commit
f29f62ddae
13 files changed
+954
-3
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 == []
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user