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

+97
View File
@@ -7,6 +7,7 @@ their entity module through the synthetic ``nsh`` package AFTER calling
"""
import asyncio
import datetime
import enum
import sys
import types
@@ -25,6 +26,7 @@ ENTITIES_STUB_NAMES = (
"homeassistant.components.media_player",
"homeassistant.components.remote",
"homeassistant.components.button",
"homeassistant.components.image",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
@@ -33,12 +35,15 @@ ENTITIES_STUB_NAMES = (
"homeassistant.helpers.redact",
"homeassistant.helpers.entity",
"homeassistant.helpers.entity_platform",
"homeassistant.util",
"homeassistant.util.dt",
"nsh.helpers",
"nsh.power",
"nsh.jellyfin",
"nsh.media_player",
"nsh.diagnostics",
"nsh.remote",
"nsh.image",
)
@@ -431,6 +436,7 @@ def install() -> None:
SENSOR="sensor",
REMOTE="remote",
BUTTON="button",
IMAGE="image",
)
sys.modules["homeassistant.const"] = ha_const
@@ -463,6 +469,93 @@ def install() -> None:
sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app")
sys.modules["homeassistant.components.sensor"] = sensor_platform
# t66: homeassistant.components.image.ImageEntity — the slice the screen
# capture entity uses, carrying the REAL contract that matters here: the
# attributes HA reads (``content_type``, ``image_last_updated``), the real
# ``__init__(hass, verify_ssl=False)`` signature, ``async_image`` as the
# bytes seam, state writing as a no-op (the harness writes no states), and
# HA 2026.10's own ``_attr_should_poll = False`` ("No need to poll image
# entities"). A subclass that forgets its base ``__init__`` fails here too.
image_platform = types.ModuleType("homeassistant.components.image")
class _EntityFace:
"""The Entity surface HA reads, backed by the ``_attr_*`` convention.
The real ImageEntity inherits all of this from Entity; the stub carries
the properties so a test can READ what the entity publishes (naming,
device, polling, availability) without a state machine.
"""
_attr_available = True
_attr_should_poll = True
_attr_translation_key = None
_attr_unique_id = None
_attr_device_info = None
_attr_extra_state_attributes = None
def async_write_ha_state(self) -> None:
return None
@property
def available(self) -> bool:
return self._attr_available
@property
def should_poll(self) -> bool:
return self._attr_should_poll
@property
def translation_key(self):
return self._attr_translation_key
@property
def unique_id(self):
return self._attr_unique_id
@property
def device_info(self):
return self._attr_device_info
@property
def extra_state_attributes(self):
return self._attr_extra_state_attributes
class ImageEntity(_EntityFace):
_attr_content_type = "image/jpeg"
_attr_image_last_updated = None
_attr_should_poll = False
def __init__(self, hass, verify_ssl: bool = False) -> None:
self.hass = hass
@property
def content_type(self) -> str:
return self._attr_content_type
@property
def image_last_updated(self):
return self._attr_image_last_updated
async def async_image(self) -> bytes | None:
raise NotImplementedError
image_platform.ImageEntity = ImageEntity
sys.modules["homeassistant.components.image"] = image_platform
# t66: homeassistant.util.dt — the timestamp helper the image entity stamps
# ``image_last_updated`` with. ``utcnow()`` really is UTC-aware, as in HA.
ha_util = types.ModuleType("homeassistant.util")
ha_util.__path__ = []
dt_util = types.ModuleType("homeassistant.util.dt")
def _utcnow():
return datetime.datetime.now(datetime.UTC)
dt_util.utcnow = _utcnow
ha_util.dt = dt_util
sys.modules["homeassistant.util"] = ha_util
sys.modules["homeassistant.util.dt"] = dt_util
helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
@@ -617,6 +710,9 @@ def load_entity_modules() -> dict:
# belongs here; tests/test_module_coverage.py sweeps EVERY module, which
# is what keeps this list from having to be complete.
"button",
# t66: the screen-capture platform is a platform like the others, so it
# loads here too (and the module-coverage sweep already walks every file)
"image",
"init",
): # "init" maps the integration __init__.py (t29 G8 async_setup)
if name == "init" and "nsh.init" in sys.modules:
@@ -633,6 +729,7 @@ def load_entity_modules() -> dict:
"media_player",
"remote",
"diagnostics",
"image",
)
}
+1
View File
@@ -211,6 +211,7 @@ def _install_stubs() -> None:
SENSOR = "sensor"
REMOTE = "remote"
BUTTON = "button"
IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS
ha_const.Platform = _Platform
sys.modules["homeassistant.const"] = ha_const
+44 -1
View File
@@ -47,6 +47,7 @@ from nsh.const import (
CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_REMOTE_COMMAND_SCRIPT,
CONF_SCREEN_CAPTURE_ENABLED,
CONF_SKIP_PAIRING,
DEFAULT_ADB_PORT,
SCRIPT_COMMANDS,
@@ -249,7 +250,7 @@ def _install_stubs() -> None:
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
ha_const.Platform = types.SimpleNamespace(
MEDIA_PLAYER="media_player", SENSOR="sensor", REMOTE="remote",
BUTTON="button",
BUTTON="button", IMAGE="image",
)
sys.modules["homeassistant.const"] = ha_const
@@ -1603,6 +1604,7 @@ OPTIONS_SUBSTEP_FIELDS = {
CONF_POWER_ENTITY,
CONF_LOGCAT_EVENTS,
CONF_ADB_COMMAND_ENABLED,
CONF_SCREEN_CAPTURE_ENABLED,
},
"receiver": {"receiver_entity", "receiver_input"},
# t64: the apps table lives in its OWN step now (the Apps sub-screen is a menu
@@ -2765,3 +2767,44 @@ class TestSetupMenu:
assert flow._host() == "10.0.0.77"
assert result["options"].get("receiver_entity") == "switch.soundbar"
assert result["options"].get("receiver_input") == "HDMI 1"
class TestScreenCaptureOption:
"""t66: the screen-capture image entity is opt-in, and OFF is the default."""
async def test_the_connection_screen_offers_it_and_defaults_off(self, tmp_path):
"""Default OFF at the SCHEMA level — the form the user is shown."""
flow = _options_flow(tmp_path)
result = await _options_open(flow, "connection")
defaults = vol_mirror.defaults_by_key(result["data_schema"])
assert defaults[CONF_SCREEN_CAPTURE_ENABLED] is False
async def test_an_untouched_toggle_stays_off(self, tmp_path):
"""A save that never renders the screen-capture row must not invent it."""
flow = _options_flow(tmp_path)
result = await _options_submit(flow, {CONF_HOST: "10.0.0.9"}, save=True)
assert result["type"] == "create_entry"
assert not result["data"].get(CONF_SCREEN_CAPTURE_ENABLED)
async def test_switching_it_on_is_stored(self, tmp_path):
flow = _options_flow(tmp_path)
result = await _options_submit(
flow, {CONF_SCREEN_CAPTURE_ENABLED: True}, save=True
)
assert result["type"] == "create_entry"
assert result["data"][CONF_SCREEN_CAPTURE_ENABLED] is True
async def test_it_can_be_switched_back_off(self, tmp_path):
flow = _options_flow(tmp_path, {CONF_SCREEN_CAPTURE_ENABLED: True})
result = await _options_submit(
flow, {CONF_SCREEN_CAPTURE_ENABLED: False}, save=True
)
assert result["data"][CONF_SCREEN_CAPTURE_ENABLED] is False
+62
View File
@@ -267,6 +267,10 @@ def _real_ha_entity(module_name, class_name, *, options=None, entity_id=None):
states=states,
data={},
config=SimpleNamespace(components=set()),
# t66: ``ImageEntity.__init__`` builds HA's shared httpx client, which
# registers a shutdown listener on ``hass.bus``. The fake hass carries
# just enough of that surface for the real base class to construct.
bus=SimpleNamespace(async_listen_once=lambda *args, **kwargs: (lambda: None)),
)
entry = SimpleNamespace(
entry_id="e1", data={}, options=dict(options or {}), title="Shield"
@@ -317,6 +321,12 @@ _REAL_HA_MATRIX = (
),
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
(
"image",
"NvidiaShieldScreenImage",
{"screen_capture_enabled": True},
"image.shield_screen_capture",
),
)
#: Classified as an entity by the sweep, but an abstract intermediate base HA
@@ -362,6 +372,58 @@ def test_the_media_player_extra_attributes_reach_the_state_machine():
assert {"app_id", "app_name"} <= set(attrs), sorted(attrs)
#: The same real 2x1 PNG fixture tests/test_screen_capture_image.py uses (the
#: wire form ``screencap -p | base64`` produces) — a real PNG, not a stand-in.
_SCREEN_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/"
"Af+4BDXgAAAAAElFTkSuQmCC"
)
def test_the_screen_capture_entity_serves_png_bytes_under_real_ha():
"""t66: HA's OWN image pieces accept what the capture path produces.
The stub suite proves the logic; this proves the contract with real HA: the
real ``ImageEntity`` base, HA's ``valid_image_content_type`` (what the image
proxy runs before it will serve bytes), and the real state write — whose
recorded state must be the capture TIMESTAMP and whose attributes must never
carry the image itself (the "never a state source" boundary, measured).
"""
_require_installed_homeassistant()
import base64
from homeassistant.components.image import valid_image_content_type
class _Poller:
connected = True
async def shell(self, cmd):
assert cmd == "screencap -p | base64"
return base64.b64encode(base64.b64decode(_SCREEN_PNG_B64)).decode()
entity, states = _real_ha_entity(
"image",
"NvidiaShieldScreenImage",
options={"screen_capture_enabled": True},
entity_id="image.shield_screen_capture",
)
entity._entry.runtime_data = SimpleNamespace(adb_poller=_Poller())
data = asyncio.run(entity.async_image())
assert isinstance(data, bytes)
assert data.startswith(b"\x89PNG\r\n\x1a\n")
assert valid_image_content_type(entity.content_type) == "image/png"
written_id, state, attrs = _write_ha_state(entity, states)
assert written_id == "image.shield_screen_capture"
# HA's image state IS ``image_last_updated`` — the timestamp, never the bytes
assert state == entity.image_last_updated.isoformat()
assert attrs["capture_status"] == "ok"
assert not any(isinstance(value, (bytes, bytearray)) for value in attrs.values())
def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree():
"""A new entity class must join the matrix above, or this test fails.
+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"
REMOTE = "remote"
BUTTON = "button"
IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS
ha_const.Platform = _Platform
sys.modules["homeassistant.const"] = ha_const