Skip to content

Commit 0f1a939

Browse files
author
SmartHardware
committed
Reuse warm Eufy streams across reconnects
1 parent 8569056 commit 0f1a939

16 files changed

Lines changed: 230 additions & 16 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.35 |
70+
| Home Assistant custom integration | Baiamonte Eufy Security 9.6.36 |
7171
| WebSocket schema | 21 |
7272
| WebSocket server | `eufy-security-ws` 3.1.0 |
7373
| Client build | `eufy-security-client` 4.1.1-mega.38 (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.36 — 2026-10-31
4+
5+
- Reuse an explicitly opened P2P camera source for a short, configurable grace period when a phone,
6+
dashboard, or TV reconnects, reducing repeated HomeBase handshakes without ever starting live
7+
video automatically.
8+
- Bound the grace period to 0–30 seconds (8 seconds by default), cancel it immediately for an
9+
explicit stop, and release any warm source when the integration unloads.
10+
311
## 9.6.35 — 2026-10-26
412

513
- Keep camera state rendering and product-event callbacks limited to bounded byte validation.

‎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.35 is an in-place successor to `fuatakgun/eufy_security` 8.2.4. It deliberately retains
5+
Version 9.6.36 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: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ def __init__(
138138
self._last_image = product_snapshot_bytes(self.product)
139139
self.product.stream_stopped_listener = self._stop_hass_streaming
140140
self._scheduled_snapshot_lock = asyncio.Lock()
141+
self._stream_stop_task: asyncio.Task | None = None
142+
self._stream_stop_generation = 0
143+
self._active_proxy_viewers = 0
141144
# ffmpeg entities
142145
self.ffmpeg = self.coordinator.hass.data[DATA_FFMPEG]
143146

@@ -149,14 +152,71 @@ async def stream_source(self) -> str | None:
149152
return self.product.stream_url
150153
if self.is_streaming is False:
151154
return None
155+
# A dashboard or phone can reconnect while the previous viewer is in
156+
# the short linger window. Reuse that explicitly opened source instead
157+
# of paying for another Eufy/HomeBase P2P handshake.
158+
self._cancel_pending_stream_stop()
152159
return self.product.stream_url
153160

161+
@callback
162+
def _cancel_pending_stream_stop(self) -> None:
163+
"""Cancel a delayed stop before a new viewer reuses the source."""
164+
self._stream_stop_generation += 1
165+
if self._stream_stop_task is not None:
166+
self._stream_stop_task.cancel()
167+
self._stream_stop_task = None
168+
169+
@callback
170+
def _schedule_p2p_stream_stop(self) -> None:
171+
"""Release an explicitly opened P2P source after a bounded linger."""
172+
self._cancel_pending_stream_stop()
173+
generation = self._stream_stop_generation
174+
delay = max(0, min(30, int(self.coordinator.config.stream_linger_seconds)))
175+
self._stream_stop_task = self.hass.async_create_background_task(
176+
self._async_stop_p2p_after_linger(generation, delay),
177+
f"baiamonte_eufy_stream_linger_{self.entity_id or self.product.model}",
178+
)
179+
180+
async def _async_stop_p2p_after_linger(
181+
self, generation: int, delay: int
182+
) -> None:
183+
"""Stop one P2P source unless another viewer claimed it."""
184+
try:
185+
if delay:
186+
await asyncio.sleep(delay)
187+
if generation != self._stream_stop_generation:
188+
return
189+
if (
190+
self.product.stream_provider != StreamProvider.P2P
191+
or not self.is_streaming
192+
):
193+
return
194+
try:
195+
async with asyncio.timeout(8):
196+
await self.product.stop_livestream()
197+
except (
198+
asyncio.TimeoutError,
199+
FailedCommandException,
200+
WebSocketConnectionException,
201+
):
202+
self.product.stream_status = StreamStatus.IDLE
203+
await self._stop_hass_streaming()
204+
_LOGGER.warning(
205+
"Camera proxy cleanup was not acknowledged; source released locally"
206+
)
207+
except asyncio.CancelledError:
208+
return
209+
finally:
210+
if generation == self._stream_stop_generation:
211+
self._stream_stop_task = None
212+
154213
async def handle_async_mjpeg_stream(self, request):
155214
"""Proxy one bounded live session and always release its Eufy source."""
156215
stream_source = await self.stream_source()
157216
if stream_source is None:
158217
return await super().handle_async_mjpeg_stream(request)
159218
stream = CameraMjpeg(self.ffmpeg.binary)
219+
self._active_proxy_viewers += 1
160220
try:
161221
async with asyncio.timeout(STREAM_TIMEOUT_SECONDS):
162222
await stream.open_camera(stream_source)
@@ -172,23 +232,13 @@ async def handle_async_mjpeg_stream(self, request):
172232
) from exc
173233
finally:
174234
await stream.close()
235+
self._active_proxy_viewers = max(0, self._active_proxy_viewers - 1)
175236
if (
176237
self.product.stream_provider == StreamProvider.P2P
177238
and self.is_streaming
239+
and self._active_proxy_viewers == 0
178240
):
179-
try:
180-
async with asyncio.timeout(8):
181-
await self.product.stop_livestream()
182-
except (
183-
asyncio.TimeoutError,
184-
FailedCommandException,
185-
WebSocketConnectionException,
186-
):
187-
self.product.stream_status = StreamStatus.IDLE
188-
await self._stop_hass_streaming()
189-
_LOGGER.warning(
190-
"Camera proxy cleanup was not acknowledged; source released locally"
191-
)
241+
self._schedule_p2p_stream_stop()
192242

193243
async def async_create_stream(self):
194244
"""Create HA playback only for a source the user already started."""
@@ -398,6 +448,10 @@ async def async_refresh_stale_snapshot(self, *, force: bool = False) -> bool:
398448

399449
async def _start_livestream(self) -> None:
400450
"""Start a P2P source for the bounded camera proxy request."""
451+
self._cancel_pending_stream_stop()
452+
if self.is_streaming:
453+
self.async_write_ha_state()
454+
return
401455
if await self.product.start_livestream() is False:
402456
raise ServiceValidationError(
403457
f"{self.product.model} opened P2P but delivered no media"
@@ -406,10 +460,27 @@ async def _start_livestream(self) -> None:
406460

407461
async def _stop_livestream(self) -> None:
408462
"""stop byte based livestream on camera"""
463+
self._cancel_pending_stream_stop()
409464
await self._stop_hass_streaming()
410465
await self.product.stop_livestream()
411466
self.async_write_ha_state()
412467

468+
async def async_will_remove_from_hass(self) -> None:
469+
"""Release any warm P2P source when the entity unloads."""
470+
self._cancel_pending_stream_stop()
471+
if (
472+
self.product.stream_provider == StreamProvider.P2P
473+
and self.is_streaming
474+
):
475+
with contextlib.suppress(
476+
asyncio.TimeoutError,
477+
FailedCommandException,
478+
WebSocketConnectionException,
479+
):
480+
async with asyncio.timeout(8):
481+
await self.product.stop_livestream()
482+
await super().async_will_remove_from_hass()
483+
413484
async def _start_rtsp_livestream(self) -> None:
414485
"""Start the device RTSP source without creating an orphan HA worker."""
415486
if await self.product.start_rtsp_livestream() is False:

‎custom_components/eufy_security/config_flow.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ def __init__(self, config_entry: ConfigEntry) -> None:
4141
ConfigField.no_stream_in_hass.name,
4242
default=self.config.no_stream_in_hass,
4343
): bool,
44+
vol.Optional(
45+
ConfigField.stream_linger_seconds.name,
46+
default=self.config.stream_linger_seconds,
47+
): vol.All(vol.Coerce(int), vol.Range(min=0, max=30)),
4448
vol.Optional(
4549
ConfigField.name_for_custom1.name,
4650
default=self.config.name_for_custom1,

‎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.35"
12+
"version": "9.6.36"
1313
}

‎custom_components/eufy_security/model.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class ConfigField(Enum):
2626
dashboard_port = 8097
2727
show_sidebar_panel = True
2828
no_stream_in_hass = False
29+
stream_linger_seconds = 8
2930
name_for_custom1 = "Custom 1"
3031
name_for_custom2 = "Custom 2"
3132
name_for_custom3 = "Custom 3"
@@ -48,6 +49,7 @@ class Config:
4849
dashboard_port: int = ConfigField.dashboard_port.value
4950
show_sidebar_panel: bool = ConfigField.show_sidebar_panel.value
5051
no_stream_in_hass: bool = ConfigField.no_stream_in_hass.value
52+
stream_linger_seconds: int = ConfigField.stream_linger_seconds.value
5153
name_for_custom1: str = ConfigField.name_for_custom1.value
5254
name_for_custom2: str = ConfigField.name_for_custom2.value
5355
name_for_custom3: str = ConfigField.name_for_custom3.value

‎custom_components/eufy_security/translations/de.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"dashboard_port": "Baiamonte bridge diagnostics port",
3636
"show_sidebar_panel": "Baiamonte Eufy Security in der Seitenleiste anzeigen",
3737
"no_stream_in_hass": "Do not use STREAM module inside Home Assistant (if you do not watch the video inside Home Assistant native streaming or you are using WebRTC, enable this to decrease CPU usage",
38+
"stream_linger_seconds": "Keep an explicitly opened P2P stream warm after the viewer disconnects (seconds, 0 disables)",
3839
"name_for_custom1": "Überschreibungsname für benutzerdefinierten Schutzmodus (1)",
3940
"name_for_custom2": "Überschreibungsname für benutzerdefinierten Schutzmodus (2)",
4041
"name_for_custom3": "Überschreibungsname für benutzerdefinierten Schutzmodus (3)"

‎custom_components/eufy_security/translations/en.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"dashboard_port": "Baiamonte bridge diagnostics port (default 8097)",
3636
"show_sidebar_panel": "Show Baiamonte Eufy Security in the sidebar",
3737
"no_stream_in_hass": "Do not use STREAM module inside Home Assistant (if you do not watch the video inside Home Assistant native streaming or you are using WebRTC, enable this to decrease CPU usage",
38+
"stream_linger_seconds": "Keep an explicitly opened P2P stream warm after the viewer disconnects (seconds, 0 disables)",
3839
"name_for_custom1": "Override Name for Custom1 Guard Mode",
3940
"name_for_custom2": "Override Name for Custom2 Guard Mode",
4041
"name_for_custom3": "Override Name for Custom3 Guard Mode"

‎custom_components/eufy_security/translations/fr.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"dashboard_port": "Baiamonte bridge diagnostics port",
3636
"show_sidebar_panel": "Afficher Baiamonte Eufy Security dans la barre latérale",
3737
"no_stream_in_hass": "Do not use STREAM module inside Home Assistant (if you do not watch the video inside Home Assistant native streaming or you are using WebRTC, enable this to decrease CPU usage",
38+
"stream_linger_seconds": "Keep an explicitly opened P2P stream warm after the viewer disconnects (seconds, 0 disables)",
3839
"name_for_custom1": "Remplacer le nom pour le mode de garde Personnalisé1",
3940
"name_for_custom2": "Remplacer le nom pour le mode de garde Personnalisé2",
4041
"name_for_custom3": "Remplacer le nom pour le mode de garde Personnalisé3"

0 commit comments

Comments
 (0)