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
@@ -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
|
||||
Reference in new issue
Block a user