Files
vigilar/vigilar/camera/ring_buffer.py
Aaron D. Lee 845a85d618 Initial commit: Vigilar DIY home security system
Phase 1 (Foundation): project skeleton, TOML config + Pydantic validation,
MQTT bus wrapper, SQLite schema (9 tables), Click CLI, process supervisor.

Phase 2 (Camera): RTSP capture via OpenCV, MOG2 motion detection with
configurable sensitivity/zones, adaptive FPS recording (2fps idle/30fps
motion) via FFmpeg subprocess, HLS live streaming, pre-motion ring buffer.

Phase 3 (Web UI): Flask + Bootstrap 5 dark theme, 6 blueprints, Jinja2
templates (dashboard, kiosk 2x2 grid, events, sensors, recordings, settings),
PWA with service worker + Web Push, full admin settings UI with config
persistence.

Remote Access: WireGuard tunnel configs, nginx reverse proxy with HLS
caching + rate limiting, bandwidth-optimized remote HLS stream (426x240
@ 500kbps), DO droplet setup script, certbot TLS.

29 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:11:27 -04:00

86 lines
2.6 KiB
Python

"""Thread-safe ring buffer for pre-motion frame storage.
Keeps a rolling window of frames so that when motion is detected,
we can include the seconds leading up to the trigger in the recording.
"""
import threading
import time
from collections import deque
from dataclasses import dataclass, field
import numpy as np
@dataclass
class TimestampedFrame:
"""A frame with its capture timestamp."""
frame: np.ndarray
timestamp: float # time.monotonic()
frame_number: int = 0
class RingBuffer:
"""Fixed-duration ring buffer for video frames.
Stores the most recent `duration_s * max_fps` frames, automatically
discarding the oldest when full. Thread-safe for single-producer,
single-consumer use (camera worker writes, recorder reads).
"""
def __init__(self, duration_s: int = 5, max_fps: int = 30):
self._max_frames = duration_s * max_fps
self._buffer: deque[TimestampedFrame] = deque(maxlen=self._max_frames)
self._lock = threading.Lock()
self._frame_count = 0
def push(self, frame: np.ndarray) -> None:
"""Add a frame to the buffer."""
with self._lock:
self._frame_count += 1
self._buffer.append(TimestampedFrame(
frame=frame,
timestamp=time.monotonic(),
frame_number=self._frame_count,
))
def flush(self) -> list[TimestampedFrame]:
"""Drain all frames from the buffer and return them in order.
Used when motion is detected to get the pre-motion context.
The buffer is cleared after flush.
"""
with self._lock:
frames = list(self._buffer)
self._buffer.clear()
return frames
def peek_latest(self) -> TimestampedFrame | None:
"""Get the most recent frame without removing it."""
with self._lock:
return self._buffer[-1] if self._buffer else None
@property
def count(self) -> int:
"""Current number of frames in the buffer."""
with self._lock:
return len(self._buffer)
@property
def capacity(self) -> int:
"""Maximum number of frames the buffer can hold."""
return self._max_frames
@property
def duration_s(self) -> float:
"""Actual duration of content in the buffer, in seconds."""
with self._lock:
if len(self._buffer) < 2:
return 0.0
return self._buffer[-1].timestamp - self._buffer[0].timestamp
def clear(self) -> None:
"""Discard all frames."""
with self._lock:
self._buffer.clear()