Skip to content

Commit 9521db4

Browse files
author
SmartHardware
committed
Commit captured snapshots after stream cleanup before reporting success
1 parent 1cd8bdc commit 9521db4

9 files changed

Lines changed: 157 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Trigger motion or ring a doorbell and verify that the matching
6767

6868
| Component | Version/contract |
6969
| --- | --- |
70-
| Home Assistant custom integration | Baiamonte Eufy Security 9.6.39 |
70+
| Home Assistant custom integration | Baiamonte Eufy Security 9.6.40 |
7171
| WebSocket schema | 21 |
7272
| WebSocket server | `eufy-security-ws` 3.1.0 |
7373
| Client build | `eufy-security-client` 4.1.1-mega.43 (upstream 4.1.1) |

‎custom_components/eufy_security/CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 9.6.40 — 2026-11-04
4+
5+
- Publish explicitly captured images after stream cleanup so an older stop/reconnect picture
6+
cannot replace the just-captured frame before publication.
7+
- Wait for and verify the durable image bytes and capture time before reporting success, and
8+
rotate the camera image token after a successful commit.
9+
- Reject a capture that finishes against a retired product during an integration reconnect.
10+
311
## 9.6.39 — 2026-11-04
412

513
- Align Pro evidence waits with the bridge's bounded 60-second helper; the Security page no longer

‎custom_components/eufy_security/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Home Assistant companion integration for **Baiamonte eufy Bridge**.
44

5-
Version 9.6.39 is an in-place successor to `fuatakgun/eufy_security` 8.2.4. It deliberately retains
5+
Version 9.6.40 is an in-place successor to `fuatakgun/eufy_security` 8.2.4. It deliberately retains
66
the `eufy_security` domain and entity unique-ID format, while replacing the visible product name,
77
documentation, diagnostics, and connection handling.
88

‎custom_components/eufy_security/camera.py‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ async def async_refresh_stale_snapshot(self, *, force: bool = False) -> bool:
361361

362362
async with self._scheduled_snapshot_lock:
363363
started = False
364+
captured_at = None
364365
try:
365366
# A user-opened stream can supply the explicit still without a
366367
# second P2P command. If the bridge instead retained a stale
@@ -413,20 +414,7 @@ async def async_refresh_stale_snapshot(self, *, force: bool = False) -> bool:
413414
await asyncio.sleep(0.5)
414415
if not is_valid_snapshot(frame):
415416
return False
416-
self._last_image = frame
417-
self.product.properties["picture"] = {
418-
"data": frame,
419-
"type": {"mime": "image/jpeg"},
420-
}
421-
self.product.image_last_updated = datetime.now(timezone.utc)
422-
self.product.snapshot_source = (
423-
"explicit_live_capture" if force else "scheduled_live_capture"
424-
)
425-
# Notify every camera/cache listener, rotate the camera token and
426-
# persist the verified frame. A direct entity state write does not
427-
# exercise the coordinator's durable snapshot cache.
428-
self.product.notify_state_update()
429-
return True
417+
captured_at = datetime.now(timezone.utc)
430418
except (
431419
asyncio.TimeoutError,
432420
FailedCommandException,
@@ -446,6 +434,24 @@ async def async_refresh_stale_snapshot(self, *, force: bool = False) -> bool:
446434
self.product.stream_status = StreamStatus.IDLE
447435
await self._stop_hass_streaming()
448436

437+
# Cleanup may replay an older bridge picture. Publish the verified
438+
# frame afterwards and confirm the durable copy before success.
439+
self._last_image = frame
440+
self.product.properties["picture"] = {
441+
"data": frame,
442+
"type": {"mime": "image/jpeg"},
443+
}
444+
self.product.image_last_updated = captured_at
445+
self.product.snapshot_source = (
446+
"explicit_live_capture" if force else "scheduled_live_capture"
447+
)
448+
self.product.notify_state_update()
449+
if not await self.coordinator.async_persist_snapshot_capture(self.product):
450+
return False
451+
self.async_update_token()
452+
self.async_write_ha_state()
453+
return True
454+
449455
async def _start_livestream(self) -> None:
450456
"""Start a P2P source for the bounded camera proxy request."""
451457
self._cancel_pending_stream_stop()

‎custom_components/eufy_security/coordinator.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,31 @@ async def _snapshot_cache_writer(self) -> None:
853853
except (OSError, ValueError) as exc:
854854
_LOGGER.warning("Snapshot disk cache write deferred: %s", type(exc).__name__)
855855

856+
async def async_persist_snapshot_capture(self, device) -> bool:
857+
"""Confirm the captured bytes/time are durable before reporting success."""
858+
if self.devices.get(device.serial_no) is not device:
859+
return False
860+
content = product_snapshot_bytes(device)
861+
captured_at = device.image_last_updated
862+
if not self._valid_snapshot(content) or captured_at is None:
863+
return False
864+
self._schedule_snapshot_cache_write()
865+
try:
866+
async with asyncio.timeout(10):
867+
await asyncio.shield(self._snapshot_cache_task)
868+
record = await asyncio.to_thread(
869+
self._read_snapshot_record,
870+
self._snapshot_cache_dir,
871+
self._snapshot_cache_key(device.serial_no),
872+
)
873+
except (OSError, asyncio.TimeoutError):
874+
return False
875+
return bool(
876+
record
877+
and record[0] == content
878+
and record[1].get("updated_at") == captured_at.isoformat()
879+
)
880+
856881
async def _restore_snapshot_cache(self) -> None:
857882
"""Restore the last valid image for cameras missing one after restart."""
858883
restored = 0

‎custom_components/eufy_security/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"iot_class": "cloud_push",
1010
"issue_tracker": "https://github.com/drahamin/baiamonte-eufy-bridge/issues",
1111
"requirements": [],
12-
"version": "9.6.39"
12+
"version": "9.6.40"
1313
}

‎custom_components/eufy_security/panel.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ async def register_panel(
2929
hass,
3030
frontend_url_path=PANEL_PATH,
3131
webcomponent_name="baiamonte-eufy-security-panel",
32-
module_url="/baiamonte_eufy_assets/evidence-panel.js?v=9.6.39",
32+
module_url="/baiamonte_eufy_assets/evidence-panel.js?v=9.6.40",
3333
sidebar_title="Baiamonte Eufy Security" if show_in_sidebar else None,
3434
sidebar_icon="mdi:cctv",
3535
require_admin=False,

‎docs/UPSTREAM_RELIABILITY_2026-11-04.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Upstream reliability changes — 2026-11-04
22

3-
Release: bridge 1.6.62, vendored client 4.1.1-mega.43, companion 9.6.39. Existing
3+
Release: bridge 1.6.62, vendored client 4.1.1-mega.43, companion 9.6.40. Existing
44
Schema-21 entities are retained. These are adaptations to our transport, not installation of
55
the successor SDK alongside the existing Eufy account session.
66

@@ -41,6 +41,11 @@ The companion waits 63 seconds for AIC replies, inside the coordinator's 64-seco
4141
operation and 70-second overall local-query budget. One Pro query and one ordinary station
4242
query can progress independently. The Pro is not retried through an unsupported direct index.
4343

44+
Live validation found a capture/publication ordering gap. Companion 9.6.40 publishes an explicit
45+
frame after stream cleanup, waits for the coalesced cache writer, verifies stored bytes and capture
46+
time, and rotates the camera proxy token before reporting success. A failed write or retired product
47+
is not reported as a fresh capture. Behavioral tests simulate an older picture replayed on stop.
48+
4449
No automatic live video is introduced. Background image maintenance uses saved covers only.
4550
An explicit user capture still opens a bounded stream through the existing capture service.
4651

‎tests/test-capture-commit.py‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Behavioral checks for capture cleanup and durable-write ordering."""
2+
import ast
3+
import asyncio
4+
import contextlib
5+
from datetime import datetime, timedelta, timezone
6+
from pathlib import Path
7+
from types import MethodType, SimpleNamespace
8+
from unittest.mock import AsyncMock
9+
10+
ROOT = Path(__file__).resolve().parents[1]
11+
FRESH = b"\xff\xd8\xff" + b"f" * 1500
12+
OLD = b"\xff\xd8\xff" + b"o" * 1500
13+
14+
15+
def method(path, name, namespace):
16+
tree = ast.parse((ROOT / path).read_text())
17+
node = next(n for n in ast.walk(tree) if isinstance(n, ast.AsyncFunctionDef) and n.name == name)
18+
node.decorator_list = []
19+
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace)
20+
return namespace[name]
21+
22+
23+
def test_capture_publishes_and_commits_after_cleanup_replays_old_picture():
24+
async def exercise():
25+
status = SimpleNamespace(IDLE="idle", STREAMING="streaming")
26+
ffmpeg = SimpleNamespace(async_get_image=AsyncMock(return_value=FRESH))
27+
namespace = dict(asyncio=asyncio, contextlib=contextlib, datetime=datetime,
28+
timedelta=timedelta, timezone=timezone, ffmpeg=ffmpeg, StreamStatus=status,
29+
STREAM_TIMEOUT_SECONDS=1, FailedCommandException=RuntimeError,
30+
WebSocketConnectionException=ConnectionError, is_valid_snapshot=lambda b: b == FRESH)
31+
capture = method("custom_components/eufy_security/camera.py", "async_refresh_stale_snapshot", namespace)
32+
product = SimpleNamespace(commands=["start_livestream", "stop_livestream"],
33+
image_last_updated=datetime(2026, 1, 1, tzinfo=timezone.utc),
34+
properties={"picture": {"data": OLD}}, stream_status=status.IDLE,
35+
notify_state_update=lambda: None)
36+
order = []
37+
async def start():
38+
product.stream_status = status.STREAMING
39+
return True
40+
async def stop():
41+
order.append("cleanup")
42+
product.stream_status = status.IDLE
43+
product.properties["picture"] = {"data": OLD}
44+
async def persist(value):
45+
assert value.properties["picture"]["data"] == FRESH
46+
assert value.stream_status == status.IDLE
47+
await asyncio.sleep(0)
48+
order.append("persisted")
49+
return True
50+
product.start_livestream = start
51+
product.stop_livestream = stop
52+
class Camera:
53+
@property
54+
def is_streaming(self): return product.stream_status == status.STREAMING
55+
camera = Camera()
56+
camera.product, camera.hass = product, object()
57+
camera._scheduled_snapshot_lock = asyncio.Lock()
58+
camera.coordinator = SimpleNamespace(async_persist_snapshot_capture=persist)
59+
camera.stream_source = AsyncMock(return_value="test-source")
60+
camera.async_update_token = lambda: order.append("token")
61+
camera.async_write_ha_state = lambda: order.append("published")
62+
assert await capture(camera, force=True)
63+
assert order == ["cleanup", "persisted", "token", "published"]
64+
assert product.properties["picture"]["data"] == FRESH
65+
assert product.snapshot_source == "explicit_live_capture"
66+
asyncio.run(exercise())
67+
68+
69+
def test_capture_commit_waits_for_bytes_and_time_and_rejects_stale_or_retired_products():
70+
async def exercise():
71+
namespace = dict(asyncio=asyncio, product_snapshot_bytes=lambda d: d.picture_bytes)
72+
commit = method("custom_components/eufy_security/coordinator.py", "async_persist_snapshot_capture", namespace)
73+
now = datetime.now(timezone.utc)
74+
product = SimpleNamespace(serial_no="camera", picture_bytes=FRESH, image_last_updated=now)
75+
stored = [None]
76+
async def write():
77+
await asyncio.sleep(0.01)
78+
stored[0] = (FRESH, {"updated_at": now.isoformat()})
79+
coordinator = SimpleNamespace(devices={"camera": product},
80+
_valid_snapshot=lambda b: b == FRESH, _snapshot_cache_dir="test-dir",
81+
_snapshot_cache_key=lambda serial: serial,
82+
_read_snapshot_record=lambda *_args: stored[0])
83+
def schedule(): coordinator._snapshot_cache_task = asyncio.create_task(write())
84+
coordinator._schedule_snapshot_cache_write = schedule
85+
assert await commit(coordinator, product)
86+
coordinator._schedule_snapshot_cache_write = lambda: setattr(coordinator,
87+
"_snapshot_cache_task", asyncio.create_task(asyncio.sleep(0)))
88+
stored[0] = (OLD, {"updated_at": now.isoformat()})
89+
assert not await commit(coordinator, product)
90+
stored[0] = (FRESH, {"updated_at": "old"})
91+
assert not await commit(coordinator, product)
92+
coordinator.devices["camera"] = object()
93+
assert not await commit(coordinator, product)
94+
asyncio.run(exercise())

0 commit comments

Comments
 (0)