Fix: Offline-Erkennung loeste Reconnect-Resets aus (Zaehlung gestoert)

Bei GRABBER_ALWAYS_ON=0 kappte kurzes Zuschauer-Aus (<img>-Reload der
Auto-Recovery) die Kameraverbindung -> Reconnect -> Tracker/Zaehlzustand
wurde zurueckgesetzt. Bei ~1 FPS riss das die Zaehlung auseinander.

- Grabber: Karenzzeit (VIEWER_GRACE_SEC, Default 15s) bevor die Kamera
  bei fehlenden Zuschauern freigegeben wird -> kein Reconnect-Churn
- Frontend: Overlay/Reload erst nach ~6s echtem Ausfall (3 Polls),
  nicht bei einzelnen langsamen Frames -> kein Verbindungs-Churn

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 10:53:46 +02:00
parent 356d0ce977
commit 264b2b3e3b
2 changed files with 38 additions and 10 deletions

24
app.py
View File

@@ -53,6 +53,11 @@ MOTION_PIXELS = int(os.environ.get("MOTION_PIXELS", "500"))
# 0 = nur zaehlen/streamen, wenn ein Browser zuschaut (gibt ESP32-Slot frei).
# 1 = dauerhaft verbinden + zaehlen, egal ob jemand zuschaut.
GRABBER_ALWAYS_ON = os.environ.get("GRABBER_ALWAYS_ON", "0") == "1"
# Karenzzeit: nach dem letzten Zuschauer noch so viele Sekunden weiterstreamen,
# bevor die Kameraverbindung freigegeben wird. Verhindert, dass kurzes
# Zuschauer-Aus (z.B. <img>-Reload durch die Offline-Erkennung) staendig
# Reconnects samt Tracker-/Zaehlzustand-Reset ausloest.
VIEWER_GRACE_SEC = float(os.environ.get("VIEWER_GRACE_SEC", "15"))
# FP16 nur auf CUDA sinnvoll -> automatisch erkennen, per Env erzwingbar.
try:
@@ -460,6 +465,7 @@ class WebcamGrabber:
self.frame_seq = 0
self.last_frame_ts = 0.0 # time.time() des letzten gelieferten Frames
self.viewers = 0
self._last_active_ts = 0.0 # letzter Zeitpunkt mit Zuschauer (Karenzzeit)
self.reset_flag = Event()
self.line = dict(SAVED_LINE)
@@ -498,6 +504,16 @@ class WebcamGrabber:
with self.lock:
return self.viewers
def _wants_stream(self) -> bool:
"""Soll der Grabber gerade die Kamera bedienen? Mit Karenzzeit, damit
kurzes Zuschauer-Aus (z.B. <img>-Reload) die Verbindung nicht kappt."""
if GRABBER_ALWAYS_ON:
return True
if self._viewer_count() > 0:
self._last_active_ts = time.time()
return True
return (time.time() - self._last_active_ts) < VIEWER_GRACE_SEC
def _current_line(self):
with self.lock:
line = dict(self.line)
@@ -564,8 +580,8 @@ class WebcamGrabber:
state = new_state()
while True:
# Ohne Always-on: nur verbinden, wenn jemand zuschaut (gibt ESP32-Slot frei).
if not GRABBER_ALWAYS_ON and self._viewer_count() == 0:
# Ohne Always-on: nur verbinden, wenn jemand zuschaut (mit Karenzzeit).
if not self._wants_stream():
if self.latest_jpeg is not None:
self._clear()
time.sleep(0.3)
@@ -587,8 +603,8 @@ class WebcamGrabber:
buf = b""
for chunk in resp.iter_content(chunk_size=8192):
if not GRABBER_ALWAYS_ON and self._viewer_count() == 0:
break # letzter Viewer weg -> Verbindung freigeben
if not self._wants_stream():
break # Zuschauer (inkl. Karenz) weg -> Verbindung freigeben
if not chunk:
continue