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

@@ -206,6 +206,7 @@
"protocol_enabled": "Add the Nvidia Remote Protocol",
"logcat_events": "Watch app launches over logcat",
"adb_command_enabled": "Allow the adb_command action",
"screen_capture_enabled": "Add a screen capture image entity",
"jellyfin_entity": "Jellyfin media player",
"power_entity": "Power switch"
},
@@ -217,6 +218,7 @@
"protocol_enabled": "The all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs the one-time PIN pairing from setup.",
"logcat_events": "Uses a second ADB stream to notice app launches the moment they happen. Leave off unless you want the faster app switch.",
"adb_command_enabled": "Exposes the adb_command action for this entry's media player. Off by default; it runs arbitrary shell commands on the Shield.",
"screen_capture_enabled": "Adds an image entity showing the Shield's screen on demand. Off by default: a capture is a full-resolution screen grab, it is a manual/diagnostic surface, and it never drives state.",
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"power_entity": "A smart plug switch, if you want reliable on/off detection."
}
@@ -380,6 +382,11 @@
"installed_apps": {
"name": "Installed apps"
}
},
"image": {
"screen_capture": {
"name": "Screen capture"
}
}
}
}