Adding recording #29
+65
-4
@@ -1,5 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask,
|
Flask,
|
||||||
@@ -12,8 +15,8 @@ from flask import (
|
|||||||
)
|
)
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from src.camera import camera
|
from src.camera import RECORDINGS_DIR, camera
|
||||||
from src.database import CameraEvent, CameraStatus, db
|
from src.database import CameraEvent, CameraRecordingEvent, CameraStatus, db
|
||||||
|
|
||||||
logging.basicConfig(level=logging.WARNING)
|
logging.basicConfig(level=logging.WARNING)
|
||||||
|
|
||||||
@@ -33,7 +36,6 @@ def create_app() -> Flask:
|
|||||||
# sync camera state with db on startup
|
# sync camera state with db on startup
|
||||||
status = CameraStatus.get()
|
status = CameraStatus.get()
|
||||||
if status.running and not camera.running:
|
if status.running and not camera.running:
|
||||||
# was running before restart — mark as stopped
|
|
||||||
CameraStatus.set_running(False)
|
CameraStatus.set_running(False)
|
||||||
|
|
||||||
return flask_app
|
return flask_app
|
||||||
@@ -50,6 +52,9 @@ def get_client_ip() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Health ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@app.get("/heartbeat")
|
@app.get("/heartbeat")
|
||||||
def heartbeat() -> tuple[Response, int]:
|
def heartbeat() -> tuple[Response, int]:
|
||||||
return (
|
return (
|
||||||
@@ -63,11 +68,17 @@ def heartbeat() -> tuple[Response, int]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pages ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index() -> str:
|
def index() -> str:
|
||||||
return render_template("index.html")
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stream endpoints ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@app.post("/camera/start")
|
@app.post("/camera/start")
|
||||||
def camera_start() -> tuple[Response, int]:
|
def camera_start() -> tuple[Response, int]:
|
||||||
status = CameraStatus.get()
|
status = CameraStatus.get()
|
||||||
@@ -95,6 +106,7 @@ def camera_stop() -> tuple[Response, int]:
|
|||||||
@app.get("/camera/status")
|
@app.get("/camera/status")
|
||||||
def camera_status() -> tuple[Response, int]:
|
def camera_status() -> tuple[Response, int]:
|
||||||
status = CameraStatus.get()
|
status = CameraStatus.get()
|
||||||
|
rec = camera.recording_status()
|
||||||
return (
|
return (
|
||||||
jsonify(
|
jsonify(
|
||||||
{
|
{
|
||||||
@@ -103,6 +115,8 @@ def camera_status() -> tuple[Response, int]:
|
|||||||
camera.wait_until_ready(timeout=0) if status.running else False
|
camera.wait_until_ready(timeout=0) if status.running else False
|
||||||
),
|
),
|
||||||
"updated_at": status.updated_at.isoformat(),
|
"updated_at": status.updated_at.isoformat(),
|
||||||
|
"recording": rec["recording"],
|
||||||
|
"recording_started_at": rec["started_at"],
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
200,
|
200,
|
||||||
@@ -133,7 +147,6 @@ def hls_segment(filename: str) -> Response:
|
|||||||
if not hls_dir.exists():
|
if not hls_dir.exists():
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
# set correct MIME types for HLS files
|
|
||||||
if filename.endswith(".m3u8"):
|
if filename.endswith(".m3u8"):
|
||||||
mimetype = "application/vnd.apple.mpegurl"
|
mimetype = "application/vnd.apple.mpegurl"
|
||||||
elif filename.endswith(".ts"):
|
elif filename.endswith(".ts"):
|
||||||
@@ -144,5 +157,53 @@ def hls_segment(filename: str) -> Response:
|
|||||||
return send_from_directory(str(hls_dir), filename, mimetype=mimetype)
|
return send_from_directory(str(hls_dir), filename, mimetype=mimetype)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Recording endpoints ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/camera/record/start")
|
||||||
|
def record_start() -> tuple[Response, int]:
|
||||||
|
if camera.recording:
|
||||||
|
return jsonify({"status": "already_recording"}), 200
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = camera.start_recording()
|
||||||
|
CameraRecordingEvent.log("record_start", get_client_ip(), str(path))
|
||||||
|
return jsonify({"status": "recording", "path": str(path)}), 200
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"status": "error", "message": str(exc)}), 409
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/camera/record/stop")
|
||||||
|
def record_stop() -> tuple[Response, int]:
|
||||||
|
if not camera.recording:
|
||||||
|
return jsonify({"status": "not_recording"}), 200
|
||||||
|
|
||||||
|
path = camera.stop_recording()
|
||||||
|
filename = Path(path).name if path else None
|
||||||
|
CameraRecordingEvent.log("record_stop", get_client_ip(), str(path))
|
||||||
|
return jsonify({"status": "stopped", "filename": filename}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/camera/recordings")
|
||||||
|
def list_recordings() -> tuple[Response, int]:
|
||||||
|
recordings = camera.list_recordings()
|
||||||
|
return jsonify(recordings), 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/camera/recordings/<filename>")
|
||||||
|
def download_recording(filename: str) -> Response:
|
||||||
|
# safety: only allow the expected filename pattern
|
||||||
|
if not filename.startswith("recording_") or not filename.endswith(".mp4"):
|
||||||
|
abort(404)
|
||||||
|
if not RECORDINGS_DIR.exists():
|
||||||
|
abort(404)
|
||||||
|
return send_from_directory(
|
||||||
|
str(RECORDINGS_DIR),
|
||||||
|
filename,
|
||||||
|
mimetype="video/mp4",
|
||||||
|
as_attachment=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True)
|
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True)
|
||||||
|
|||||||
+194
-15
@@ -1,9 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -19,6 +22,7 @@ except ImportError:
|
|||||||
PICAMERA_AVAILABLE = False
|
PICAMERA_AVAILABLE = False
|
||||||
|
|
||||||
HLS_DIR = Path("/tmp/hls")
|
HLS_DIR = Path("/tmp/hls")
|
||||||
|
RECORDINGS_DIR = Path("/var/lib/birdcam/recordings")
|
||||||
SEGMENT_DURATION = 2 # seconds per segment
|
SEGMENT_DURATION = 2 # seconds per segment
|
||||||
SEGMENT_COUNT = 5 # segments to keep in playlist
|
SEGMENT_COUNT = 5 # segments to keep in playlist
|
||||||
BITRATE = 2_000_000 # 2 Mbps — adjust for bandwidth needs
|
BITRATE = 2_000_000 # 2 Mbps — adjust for bandwidth needs
|
||||||
@@ -47,13 +51,21 @@ class Camera:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._picam: Picamera2 | None = None
|
self._picam: Picamera2 | None = None
|
||||||
self._encoder: H264Encoder | None = None
|
self._stream_encoder: H264Encoder | None = None
|
||||||
|
self._record_encoder: H264Encoder | None = None
|
||||||
self._ffmpeg: subprocess.Popen[bytes] | None = None
|
self._ffmpeg: subprocess.Popen[bytes] | None = None
|
||||||
|
self._record_ffmpeg: subprocess.Popen[bytes] | None = None
|
||||||
self._output: PipeOutput | None = None
|
self._output: PipeOutput | None = None
|
||||||
self._ready_event = threading.Event()
|
self._ready_event = threading.Event()
|
||||||
self._watch_thread: threading.Thread | None = None
|
self._watch_thread: threading.Thread | None = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self.running = False
|
self.running = False
|
||||||
|
self.recording = False
|
||||||
|
self._current_recording_path: Path | None = None
|
||||||
|
self._recording_started_at: datetime | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# ── Streaming ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self.running:
|
if self.running:
|
||||||
@@ -65,7 +77,7 @@ class Camera:
|
|||||||
HLS_DIR.mkdir(parents=True)
|
HLS_DIR.mkdir(parents=True)
|
||||||
|
|
||||||
if not PICAMERA_AVAILABLE:
|
if not PICAMERA_AVAILABLE:
|
||||||
logger.info("Mock camera started")
|
logger.info("Mock camera started (picamera2 not available)")
|
||||||
self.running = True
|
self.running = True
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -75,11 +87,11 @@ class Camera:
|
|||||||
"-loglevel",
|
"-loglevel",
|
||||||
"warning",
|
"warning",
|
||||||
"-f",
|
"-f",
|
||||||
"h264", # input is raw H.264
|
"h264",
|
||||||
"-i",
|
"-i",
|
||||||
"pipe:0", # read from stdin
|
"pipe:0",
|
||||||
"-c:v",
|
"-c:v",
|
||||||
"copy", # no re-encoding — pass through directly
|
"copy",
|
||||||
"-hls_time",
|
"-hls_time",
|
||||||
str(SEGMENT_DURATION),
|
str(SEGMENT_DURATION),
|
||||||
"-hls_list_size",
|
"-hls_list_size",
|
||||||
@@ -99,7 +111,6 @@ class Camera:
|
|||||||
)
|
)
|
||||||
self._output = PipeOutput(self._ffmpeg)
|
self._output = PipeOutput(self._ffmpeg)
|
||||||
|
|
||||||
# configure picamera2 with H.264 encoder
|
|
||||||
self._picam = Picamera2()
|
self._picam = Picamera2()
|
||||||
config = self._picam.create_video_configuration(
|
config = self._picam.create_video_configuration(
|
||||||
main={"size": (1280, 720)},
|
main={"size": (1280, 720)},
|
||||||
@@ -107,19 +118,18 @@ class Camera:
|
|||||||
self._picam.configure(config)
|
self._picam.configure(config)
|
||||||
self._picam.set_controls(
|
self._picam.set_controls(
|
||||||
{
|
{
|
||||||
"Brightness": 0.1, # -1.0 to 1.0, default 0.0
|
"Brightness": 0.1,
|
||||||
"Contrast": 1.1, # 0.0 to 32.0, default 1.0
|
"Contrast": 1.1,
|
||||||
"Saturation": 1.1, # 0.0 to 32.0, default 1.0
|
"Saturation": 1.1,
|
||||||
"Sharpness": 1.0, # 0.0 to 16.0, default 1.0
|
"Sharpness": 1.0,
|
||||||
"AwbEnable": True, # auto white balance
|
"AwbEnable": True,
|
||||||
"AeEnable": True, # auto exposure
|
"AeEnable": True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self._encoder = H264Encoder(bitrate=BITRATE)
|
self._stream_encoder = H264Encoder(bitrate=BITRATE)
|
||||||
buffered = io.BufferedWriter(self._output)
|
buffered = io.BufferedWriter(self._output)
|
||||||
self._picam.start_recording(self._encoder, FileOutput(buffered))
|
self._picam.start_recording(self._stream_encoder, FileOutput(buffered))
|
||||||
|
|
||||||
# watch for the playlist to appear — signals first segment is ready
|
|
||||||
self._stop_event.clear()
|
self._stop_event.clear()
|
||||||
self._ready_event.clear()
|
self._ready_event.clear()
|
||||||
self._watch_thread = threading.Thread(target=self._watch_playlist, daemon=True)
|
self._watch_thread = threading.Thread(target=self._watch_playlist, daemon=True)
|
||||||
@@ -147,6 +157,11 @@ class Camera:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
if not self.running:
|
if not self.running:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# stop any active recording first
|
||||||
|
if self.recording:
|
||||||
|
self.stop_recording()
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
self._ready_event.set() # unblock any waiters
|
self._ready_event.set() # unblock any waiters
|
||||||
|
|
||||||
@@ -174,6 +189,170 @@ class Camera:
|
|||||||
self.running = False
|
self.running = False
|
||||||
logger.info("Camera stopped")
|
logger.info("Camera stopped")
|
||||||
|
|
||||||
|
# ── Recording ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_recording(self) -> Path:
|
||||||
|
"""
|
||||||
|
Start recording to an MP4 file. Can run independently of or
|
||||||
|
simultaneously with the HLS stream. Returns the output file path.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self.recording:
|
||||||
|
raise RuntimeError("Already recording")
|
||||||
|
|
||||||
|
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
||||||
|
output_path = RECORDINGS_DIR / f"recording_{timestamp}.mp4"
|
||||||
|
|
||||||
|
if not PICAMERA_AVAILABLE:
|
||||||
|
# mock mode — create an empty placeholder file
|
||||||
|
output_path.touch()
|
||||||
|
self._current_recording_path = output_path
|
||||||
|
self._recording_started_at = datetime.now(UTC)
|
||||||
|
self.recording = True
|
||||||
|
logger.info("Mock recording started: %s", output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
if self._picam is None:
|
||||||
|
# camera not yet streaming — start it temporarily in record-only mode
|
||||||
|
self._picam = Picamera2()
|
||||||
|
config = self._picam.create_video_configuration(
|
||||||
|
main={"size": (1280, 720)},
|
||||||
|
)
|
||||||
|
self._picam.configure(config)
|
||||||
|
self._picam.set_controls(
|
||||||
|
{
|
||||||
|
"Brightness": 0.1,
|
||||||
|
"Contrast": 1.1,
|
||||||
|
"Saturation": 1.1,
|
||||||
|
"Sharpness": 1.0,
|
||||||
|
"AwbEnable": True,
|
||||||
|
"AeEnable": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# pipe raw H.264 → ffmpeg → MP4 container
|
||||||
|
ffmpeg_cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-loglevel",
|
||||||
|
"warning",
|
||||||
|
"-f",
|
||||||
|
"h264",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
"-c:v",
|
||||||
|
"copy",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
str(output_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
self._record_ffmpeg = subprocess.Popen(
|
||||||
|
ffmpeg_cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
record_pipe = PipeOutput(self._record_ffmpeg)
|
||||||
|
record_buffered = io.BufferedWriter(record_pipe)
|
||||||
|
|
||||||
|
self._record_encoder = H264Encoder(bitrate=BITRATE)
|
||||||
|
|
||||||
|
if self.running:
|
||||||
|
# camera already running for streaming — add a second encoder output
|
||||||
|
self._picam.start_encoder(
|
||||||
|
self._record_encoder,
|
||||||
|
FileOutput(record_buffered),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# no stream active — start camera just for recording
|
||||||
|
self._picam.start_recording(
|
||||||
|
self._record_encoder,
|
||||||
|
FileOutput(record_buffered),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._current_recording_path = output_path
|
||||||
|
self._recording_started_at = datetime.now(UTC)
|
||||||
|
self.recording = True
|
||||||
|
logger.info("Recording started: %s", output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def stop_recording(self) -> Path | None:
|
||||||
|
"""Stop the active recording and finalise the MP4 file."""
|
||||||
|
with self._lock:
|
||||||
|
if not self.recording:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = self._current_recording_path
|
||||||
|
|
||||||
|
if PICAMERA_AVAILABLE and self._record_encoder is not None:
|
||||||
|
if self.running:
|
||||||
|
# streaming still active — only stop the recording encoder
|
||||||
|
self._picam.stop_encoder(self._record_encoder) # type: ignore[union-attr]
|
||||||
|
else:
|
||||||
|
# recording-only mode — stop the whole camera
|
||||||
|
if self._picam:
|
||||||
|
self._picam.stop_recording()
|
||||||
|
self._picam.close()
|
||||||
|
self._picam = None
|
||||||
|
|
||||||
|
self._record_encoder = None
|
||||||
|
|
||||||
|
if self._record_ffmpeg:
|
||||||
|
if self._record_ffmpeg.stdin:
|
||||||
|
self._record_ffmpeg.stdin.close()
|
||||||
|
try:
|
||||||
|
self._record_ffmpeg.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._record_ffmpeg.kill()
|
||||||
|
self._record_ffmpeg = None
|
||||||
|
|
||||||
|
self.recording = False
|
||||||
|
self._current_recording_path = None
|
||||||
|
self._recording_started_at = None
|
||||||
|
logger.info("Recording stopped: %s", path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def recording_status(self) -> dict[str, object]:
|
||||||
|
"""Return current recording state for the API."""
|
||||||
|
return {
|
||||||
|
"recording": self.recording,
|
||||||
|
"path": (
|
||||||
|
str(self._current_recording_path)
|
||||||
|
if self._current_recording_path
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"started_at": (
|
||||||
|
self._recording_started_at.isoformat()
|
||||||
|
if self._recording_started_at
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_recordings(self) -> list[dict[str, object]]:
|
||||||
|
"""Return metadata for all saved recordings, newest first."""
|
||||||
|
if not RECORDINGS_DIR.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
recordings = []
|
||||||
|
for f in sorted(RECORDINGS_DIR.glob("recording_*.mp4"), reverse=True):
|
||||||
|
stat = f.stat()
|
||||||
|
recordings.append(
|
||||||
|
{
|
||||||
|
"filename": f.name,
|
||||||
|
"size_bytes": stat.st_size,
|
||||||
|
"created_at": datetime.fromtimestamp(
|
||||||
|
stat.st_ctime, tz=UTC
|
||||||
|
).isoformat(),
|
||||||
|
"duration_seconds": None, # could be derived via ffprobe if needed
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return recordings
|
||||||
|
|
||||||
|
# ── Properties ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hls_dir(self) -> Path:
|
def hls_dir(self) -> Path:
|
||||||
return HLS_DIR
|
return HLS_DIR
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class CameraStatus(Base):
|
|||||||
|
|
||||||
class CameraEvent(Base):
|
class CameraEvent(Base):
|
||||||
__tablename__ = "camera_events"
|
__tablename__ = "camera_events"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
action: Mapped[str] = mapped_column(String(10), nullable=False) # 'start' | 'stop'
|
action: Mapped[str] = mapped_column(String(10), nullable=False) # 'start' | 'stop'
|
||||||
ip_address: Mapped[str] = mapped_column(String(45), nullable=False)
|
ip_address: Mapped[str] = mapped_column(String(45), nullable=False)
|
||||||
@@ -73,3 +74,44 @@ class CameraEvent(Base):
|
|||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CameraRecordingEvent(Base):
|
||||||
|
"""Audit log for recording start/stop actions."""
|
||||||
|
|
||||||
|
__tablename__ = "camera_recording_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
action: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False
|
||||||
|
) # 'record_start' | 'record_stop'
|
||||||
|
ip_address: Mapped[str] = mapped_column(String(45), nullable=False)
|
||||||
|
file_path: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def log(action: str, ip_address: str, file_path: str = "") -> CameraRecordingEvent:
|
||||||
|
event = CameraRecordingEvent(
|
||||||
|
action=action,
|
||||||
|
ip_address=ip_address,
|
||||||
|
file_path=file_path,
|
||||||
|
)
|
||||||
|
db.session.add(event)
|
||||||
|
db.session.commit()
|
||||||
|
return event
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def recent(limit: int = 50) -> list[CameraRecordingEvent]:
|
||||||
|
return list(
|
||||||
|
db.session.execute(
|
||||||
|
db.select(CameraRecordingEvent)
|
||||||
|
.order_by(CameraRecordingEvent.timestamp.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|||||||
+262
-41
@@ -44,6 +44,24 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#preview.is-recording {
|
||||||
|
outline: 2px solid #ef4444;
|
||||||
|
outline-offset: 2px;
|
||||||
|
animation: rec-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rec-pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
outline-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
outline-color: #7f1d1d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#stream-video {
|
#stream-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -54,6 +72,10 @@
|
|||||||
#placeholder {
|
#placeholder {
|
||||||
color: #555;
|
color: #555;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
@@ -70,7 +92,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s, background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
@@ -88,11 +110,128 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#btn-record {
|
||||||
|
background: #3f3f3f;
|
||||||
|
color: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-record.is-recording {
|
||||||
|
background: #b91c1c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-record.is-recording .rec-dot {
|
||||||
|
animation: blink 1s step-start infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.live {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
#status {
|
#status {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #888;
|
color: #888;
|
||||||
min-height: 1.2em;
|
min-height: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#recordings-section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recordings-section h2 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recordings-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item .rec-name {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #ccc;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item .rec-meta {
|
||||||
|
color: #555;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item a {
|
||||||
|
color: #22c55e;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item a:hover {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
#recordings-empty {
|
||||||
|
color: #444;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -101,71 +240,165 @@
|
|||||||
|
|
||||||
<div id="preview">
|
<div id="preview">
|
||||||
<div id="placeholder">
|
<div id="placeholder">
|
||||||
<div class="dot" id="status-dot"></div>
|
|
||||||
<span id="placeholder-text">Checking stream...</span>
|
<span id="placeholder-text">Checking stream...</span>
|
||||||
</div>
|
</div>
|
||||||
<video id="stream-video" autoplay muted playsinline></video>
|
<video id="stream-video" autoplay muted playsinline></video>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls" id="controls"></div>
|
<div class="controls" id="controls"></div>
|
||||||
|
|
||||||
<p id="status"></p>
|
<p id="status"></p>
|
||||||
|
|
||||||
<!-- hls.js from CDN -->
|
<div id="recordings-section">
|
||||||
|
<h2>Recordings</h2>
|
||||||
|
<div id="recordings-list">
|
||||||
|
<p id="recordings-empty">No recordings yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const video = document.getElementById("stream-video");
|
const video = document.getElementById("stream-video");
|
||||||
const placeholder = document.getElementById("placeholder");
|
const placeholder = document.getElementById("placeholder");
|
||||||
const placeholderText = document.getElementById("placeholder-text");
|
const placeholderText = document.getElementById("placeholder-text");
|
||||||
const statusDot = document.getElementById("status-dot");
|
|
||||||
const controls = document.getElementById("controls");
|
const controls = document.getElementById("controls");
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
|
const preview = document.getElementById("preview");
|
||||||
|
const recordingsList = document.getElementById("recordings-list");
|
||||||
|
|
||||||
let hls = null;
|
let hls = null;
|
||||||
let pollInterval = null;
|
let pollInterval = null;
|
||||||
|
let isRecording = false;
|
||||||
|
|
||||||
|
function setStatus(msg) { statusEl.textContent = msg; }
|
||||||
|
|
||||||
|
function fmtBytes(b) {
|
||||||
|
if (b < 1024) return b + " B";
|
||||||
|
if (b < 1048576) return (b / 1024).toFixed(1) + " KB";
|
||||||
|
return (b / 1048576).toFixed(1) + " MB";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) { return new Date(iso).toLocaleString(); }
|
||||||
|
|
||||||
|
// ── Recordings list ─────────────────────────────────────────────────────
|
||||||
|
async function refreshRecordings() {
|
||||||
|
const res = await fetch("/camera/recordings");
|
||||||
|
const list = await res.json();
|
||||||
|
recordingsList.innerHTML = "";
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
recordingsList.innerHTML = '<p id="recordings-empty">No recordings yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.forEach(rec => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "recording-item";
|
||||||
|
item.innerHTML =
|
||||||
|
'<span class="rec-name">' + rec.filename + '</span>' +
|
||||||
|
'<span class="rec-meta">' + fmtBytes(rec.size_bytes) + ' · ' + fmtDate(rec.created_at) + '</span>' +
|
||||||
|
'<a href="/camera/recordings/' + rec.filename + '" download>Download</a>';
|
||||||
|
recordingsList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Record button ───────────────────────────────────────────────────────
|
||||||
|
function createRecordButton() {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.id = "btn-record";
|
||||||
|
const dot = document.createElement("span");
|
||||||
|
dot.className = "rec-dot";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "rec-label";
|
||||||
|
|
||||||
|
if (isRecording) {
|
||||||
|
btn.classList.add("is-recording");
|
||||||
|
label.textContent = "Stop Recording";
|
||||||
|
} else {
|
||||||
|
label.textContent = "Record";
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.appendChild(dot);
|
||||||
|
btn.appendChild(label);
|
||||||
|
btn.addEventListener("click", toggleRecording);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRecording() {
|
||||||
|
const btn = document.getElementById("btn-record");
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
|
if (!isRecording) {
|
||||||
|
setStatus("Starting recording...");
|
||||||
|
const res = await fetch("/camera/record/start", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === "recording" || data.status === "already_recording") {
|
||||||
|
isRecording = true;
|
||||||
|
setStatus("Recording");
|
||||||
|
} else {
|
||||||
|
setStatus("Failed to start recording: " + (data.message || "unknown error"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setStatus("Stopping recording...");
|
||||||
|
await fetch("/camera/record/stop", { method: "POST" });
|
||||||
|
isRecording = false;
|
||||||
|
setStatus("Recording saved");
|
||||||
|
await refreshRecordings();
|
||||||
|
}
|
||||||
|
|
||||||
|
const streaming = !!document.getElementById("btn-stop");
|
||||||
|
renderControls(streaming);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────────
|
||||||
function renderControls(running) {
|
function renderControls(running) {
|
||||||
controls.innerHTML = "";
|
controls.innerHTML = "";
|
||||||
|
|
||||||
if (running) {
|
if (running) {
|
||||||
const stop = document.createElement("button");
|
const stop = document.createElement("button");
|
||||||
stop.id = "btn-stop";
|
stop.id = "btn-stop";
|
||||||
stop.textContent = "Stop";
|
stop.textContent = "Stop Stream";
|
||||||
stop.addEventListener("click", stopStream);
|
stop.addEventListener("click", stopStream);
|
||||||
controls.appendChild(stop);
|
controls.appendChild(stop);
|
||||||
|
|
||||||
const badge = document.createElement("span");
|
|
||||||
badge.className = "badge live";
|
|
||||||
badge.textContent = "● Live";
|
|
||||||
controls.appendChild(badge);
|
|
||||||
} else {
|
} else {
|
||||||
const start = document.createElement("button");
|
const start = document.createElement("button");
|
||||||
start.id = "btn-start";
|
start.id = "btn-start";
|
||||||
start.textContent = "Start";
|
start.textContent = "Start Stream";
|
||||||
start.addEventListener("click", startStream);
|
start.addEventListener("click", startStream);
|
||||||
controls.appendChild(start);
|
controls.appendChild(start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
controls.appendChild(createRecordButton());
|
||||||
|
|
||||||
|
if (running) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "badge live";
|
||||||
|
badge.textContent = "Live";
|
||||||
|
controls.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecording) {
|
||||||
|
preview.classList.add("is-recording");
|
||||||
|
} else {
|
||||||
|
preview.classList.remove("is-recording");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showOffline(message = "Stream is offline") {
|
// ── Video display ────────────────────────────────────────────────────────
|
||||||
|
function showOffline(message) {
|
||||||
video.style.display = "none";
|
video.style.display = "none";
|
||||||
video.src = "";
|
video.src = "";
|
||||||
placeholder.style.display = "flex";
|
placeholder.style.display = "flex";
|
||||||
placeholderText.textContent = message;
|
placeholderText.textContent = message || "Stream is offline";
|
||||||
statusDot.classList.remove("live");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLive() {
|
function showLive() {
|
||||||
placeholder.style.display = "none";
|
placeholder.style.display = "none";
|
||||||
video.style.display = "block";
|
video.style.display = "block";
|
||||||
statusDot.classList.add("live");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HLS ─────────────────────────────────────────────────────────────────
|
// ── HLS ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function startHls() {
|
function startHls() {
|
||||||
const src = "/camera/hls/stream.m3u8";
|
const src = "/camera/hls/stream.m3u8";
|
||||||
|
|
||||||
if (hls) { hls.destroy(); hls = null; }
|
if (hls) { hls.destroy(); hls = null; }
|
||||||
|
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
@@ -179,7 +412,6 @@
|
|||||||
});
|
});
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
console.error("HLS fatal error:", data);
|
|
||||||
showOffline("Stream error — reloading...");
|
showOffline("Stream error — reloading...");
|
||||||
setTimeout(attachToStream, 3000);
|
setTimeout(attachToStream, 3000);
|
||||||
}
|
}
|
||||||
@@ -201,18 +433,14 @@
|
|||||||
video.style.display = "none";
|
video.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status polling ───────────────────────────────────────────────────────
|
// ── Status polling ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function fetchStatus() {
|
async function fetchStatus() {
|
||||||
const res = await fetch("/camera/status");
|
const res = await fetch("/camera/status");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(msg) {
|
async function waitForReady(maxAttempts) {
|
||||||
statusEl.textContent = msg;
|
maxAttempts = maxAttempts || 40;
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForReady(maxAttempts = 40) {
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
const data = await fetchStatus();
|
const data = await fetchStatus();
|
||||||
if (data.ready) return true;
|
if (data.ready) return true;
|
||||||
@@ -221,9 +449,10 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called on page load and after stream errors — attach if already live
|
|
||||||
async function attachToStream() {
|
async function attachToStream() {
|
||||||
const data = await fetchStatus();
|
const data = await fetchStatus();
|
||||||
|
isRecording = data.recording || false;
|
||||||
|
|
||||||
if (data.ready) {
|
if (data.ready) {
|
||||||
startHls();
|
startHls();
|
||||||
renderControls(true);
|
renderControls(true);
|
||||||
@@ -259,15 +488,10 @@
|
|||||||
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stream start / stop ──────────────────────────────────────────────────
|
// ── Stream start / stop ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async function startStream() {
|
async function startStream() {
|
||||||
// guard: re-check status in case another client just started it
|
|
||||||
const current = await fetchStatus();
|
const current = await fetchStatus();
|
||||||
if (current.running || current.ready) {
|
if (current.running || current.ready) { await attachToStream(); return; }
|
||||||
await attachToStream();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.getElementById("btn-start");
|
const btn = document.getElementById("btn-start");
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
@@ -275,7 +499,6 @@
|
|||||||
placeholderText.textContent = "Starting...";
|
placeholderText.textContent = "Starting...";
|
||||||
|
|
||||||
await fetch("/camera/start", { method: "POST" });
|
await fetch("/camera/start", { method: "POST" });
|
||||||
|
|
||||||
setStatus("Waiting for first segment...");
|
setStatus("Waiting for first segment...");
|
||||||
const ready = await waitForReady();
|
const ready = await waitForReady();
|
||||||
|
|
||||||
@@ -301,14 +524,12 @@
|
|||||||
startOfflinePoll();
|
startOfflinePoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await attachToStream();
|
await attachToStream();
|
||||||
|
await refreshRecordings();
|
||||||
const data = await fetchStatus();
|
const data = await fetchStatus();
|
||||||
if (!data.running && !data.ready) {
|
if (!data.running && !data.ready) startOfflinePoll();
|
||||||
startOfflinePoll();
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+93
-2
@@ -17,6 +17,9 @@ def client() -> Generator[FlaskClient, Any, Any]:
|
|||||||
db.drop_all()
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stream tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_heartbeat_status_code(client: FlaskClient) -> None:
|
def test_heartbeat_status_code(client: FlaskClient) -> None:
|
||||||
response = client.get("/heartbeat")
|
response = client.get("/heartbeat")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -35,7 +38,7 @@ def test_camera_stop(client: FlaskClient) -> None:
|
|||||||
assert response.get_json()["status"] in ("stopped", "already_stopped")
|
assert response.get_json()["status"] in ("stopped", "already_stopped")
|
||||||
|
|
||||||
|
|
||||||
def test_double_start_is_idempotent(client):
|
def test_double_start_is_idempotent(client: FlaskClient) -> None:
|
||||||
client.post("/camera/start")
|
client.post("/camera/start")
|
||||||
res = client.post("/camera/start")
|
res = client.post("/camera/start")
|
||||||
assert res.get_json()["status"] == "already_running"
|
assert res.get_json()["status"] == "already_running"
|
||||||
@@ -48,9 +51,19 @@ def test_camera_status(client: FlaskClient) -> None:
|
|||||||
assert "running" in data
|
assert "running" in data
|
||||||
assert "ready" in data
|
assert "ready" in data
|
||||||
assert "updated_at" in data
|
assert "updated_at" in data
|
||||||
|
# recording fields must always be present
|
||||||
|
assert "recording" in data
|
||||||
|
assert "recording_started_at" in data
|
||||||
|
|
||||||
|
|
||||||
def test_camera_log(client):
|
def test_camera_status_includes_recording_false_by_default(client: FlaskClient) -> None:
|
||||||
|
res = client.get("/camera/status")
|
||||||
|
data = res.get_json()
|
||||||
|
assert data["recording"] is False
|
||||||
|
assert data["recording_started_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_log(client: FlaskClient) -> None:
|
||||||
client.post("/camera/start")
|
client.post("/camera/start")
|
||||||
client.post("/camera/stop")
|
client.post("/camera/stop")
|
||||||
res = client.get("/camera/log")
|
res = client.get("/camera/log")
|
||||||
@@ -72,3 +85,81 @@ def test_index_page(client: FlaskClient) -> None:
|
|||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert b"Pi Camera" in response.data
|
assert b"Pi Camera" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
# ── Recording tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_start_returns_200(client: FlaskClient) -> None:
|
||||||
|
res = client.post("/camera/record/start")
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.get_json()
|
||||||
|
assert data["status"] in ("recording", "already_recording")
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_stop_when_not_recording(client: FlaskClient) -> None:
|
||||||
|
res = client.post("/camera/record/stop")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.get_json()["status"] == "not_recording"
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_start_then_stop(client: FlaskClient) -> None:
|
||||||
|
start = client.post("/camera/record/start")
|
||||||
|
assert start.get_json()["status"] == "recording"
|
||||||
|
|
||||||
|
stop = client.post("/camera/record/stop")
|
||||||
|
assert stop.status_code == 200
|
||||||
|
assert stop.get_json()["status"] == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_record_start_is_idempotent(client: FlaskClient) -> None:
|
||||||
|
client.post("/camera/record/start")
|
||||||
|
res = client.post("/camera/record/start")
|
||||||
|
assert res.get_json()["status"] == "already_recording"
|
||||||
|
# clean up
|
||||||
|
client.post("/camera/record/stop")
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_reflects_recording_state(client: FlaskClient) -> None:
|
||||||
|
client.post("/camera/record/start")
|
||||||
|
status = client.get("/camera/status").get_json()
|
||||||
|
assert status["recording"] is True
|
||||||
|
assert status["recording_started_at"] is not None
|
||||||
|
|
||||||
|
client.post("/camera/record/stop")
|
||||||
|
status = client.get("/camera/status").get_json()
|
||||||
|
assert status["recording"] is False
|
||||||
|
assert status["recording_started_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_and_record_simultaneously(client: FlaskClient) -> None:
|
||||||
|
"""Stream and record can be active at the same time without interference."""
|
||||||
|
client.post("/camera/start")
|
||||||
|
rec = client.post("/camera/record/start")
|
||||||
|
assert rec.get_json()["status"] in ("recording", "already_recording")
|
||||||
|
|
||||||
|
status = client.get("/camera/status").get_json()
|
||||||
|
assert status["running"] is True
|
||||||
|
assert status["recording"] is True
|
||||||
|
|
||||||
|
# stopping stream does not affect recording state report from camera object
|
||||||
|
client.post("/camera/stop")
|
||||||
|
# recording was stopped as part of camera.stop() — that is expected behaviour
|
||||||
|
# (the camera itself cleans up on stop)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_recordings_empty(client: FlaskClient) -> None:
|
||||||
|
res = client.get("/camera/recordings")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.get_json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_recording_invalid_filename(client: FlaskClient) -> None:
|
||||||
|
# filename pattern not matching should 404
|
||||||
|
res = client.get("/camera/recordings/../../etc/passwd")
|
||||||
|
assert res.status_code in (404, 400)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_recording_wrong_prefix(client: FlaskClient) -> None:
|
||||||
|
res = client.get("/camera/recordings/evil.mp4")
|
||||||
|
assert res.status_code == 404
|
||||||
|
|||||||
Reference in New Issue
Block a user