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