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

+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.