diff --git a/src/app.py b/src/app.py index 9b9ec16..cd49d1a 100644 --- a/src/app.py +++ b/src/app.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import logging from datetime import UTC, datetime +from pathlib import Path from flask import ( Flask, @@ -12,8 +15,8 @@ from flask import ( ) from werkzeug.middleware.proxy_fix import ProxyFix -from src.camera import camera -from src.database import CameraEvent, CameraStatus, db +from src.camera import RECORDINGS_DIR, camera +from src.database import CameraEvent, CameraRecordingEvent, CameraStatus, db logging.basicConfig(level=logging.WARNING) @@ -33,7 +36,6 @@ def create_app() -> Flask: # sync camera state with db on startup status = CameraStatus.get() if status.running and not camera.running: - # was running before restart — mark as stopped CameraStatus.set_running(False) return flask_app @@ -50,6 +52,9 @@ def get_client_ip() -> str: ) +# ── Health ─────────────────────────────────────────────────────────────────── + + @app.get("/heartbeat") def heartbeat() -> tuple[Response, int]: return ( @@ -63,11 +68,17 @@ def heartbeat() -> tuple[Response, int]: ) +# ── Pages ──────────────────────────────────────────────────────────────────── + + @app.get("/") def index() -> str: return render_template("index.html") +# ── Stream endpoints ────────────────────────────────────────────────────────── + + @app.post("/camera/start") def camera_start() -> tuple[Response, int]: status = CameraStatus.get() @@ -95,6 +106,7 @@ def camera_stop() -> tuple[Response, int]: @app.get("/camera/status") def camera_status() -> tuple[Response, int]: status = CameraStatus.get() + rec = camera.recording_status() return ( jsonify( { @@ -103,6 +115,8 @@ def camera_status() -> tuple[Response, int]: camera.wait_until_ready(timeout=0) if status.running else False ), "updated_at": status.updated_at.isoformat(), + "recording": rec["recording"], + "recording_started_at": rec["started_at"], } ), 200, @@ -133,7 +147,6 @@ def hls_segment(filename: str) -> Response: if not hls_dir.exists(): abort(404) - # set correct MIME types for HLS files if filename.endswith(".m3u8"): mimetype = "application/vnd.apple.mpegurl" elif filename.endswith(".ts"): @@ -144,5 +157,53 @@ def hls_segment(filename: str) -> Response: 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/") +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__": app.run(host="0.0.0.0", port=5000, debug=True, threaded=True) diff --git a/src/camera.py b/src/camera.py index dd19174..9305761 100644 --- a/src/camera.py +++ b/src/camera.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import io import logging import shutil import subprocess import threading import time +from datetime import UTC, datetime from pathlib import Path logger = logging.getLogger(__name__) @@ -19,6 +22,7 @@ except ImportError: PICAMERA_AVAILABLE = False HLS_DIR = Path("/tmp/hls") +RECORDINGS_DIR = Path("/var/lib/birdcam/recordings") SEGMENT_DURATION = 2 # seconds per segment SEGMENT_COUNT = 5 # segments to keep in playlist BITRATE = 2_000_000 # 2 Mbps — adjust for bandwidth needs @@ -47,13 +51,21 @@ class Camera: def __init__(self) -> 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._record_ffmpeg: subprocess.Popen[bytes] | None = None self._output: PipeOutput | None = None self._ready_event = threading.Event() self._watch_thread: threading.Thread | None = None self._stop_event = threading.Event() 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: if self.running: @@ -65,7 +77,7 @@ class Camera: HLS_DIR.mkdir(parents=True) if not PICAMERA_AVAILABLE: - logger.info("Mock camera started") + logger.info("Mock camera started (picamera2 not available)") self.running = True return @@ -75,11 +87,11 @@ class Camera: "-loglevel", "warning", "-f", - "h264", # input is raw H.264 + "h264", "-i", - "pipe:0", # read from stdin + "pipe:0", "-c:v", - "copy", # no re-encoding — pass through directly + "copy", "-hls_time", str(SEGMENT_DURATION), "-hls_list_size", @@ -99,7 +111,6 @@ class Camera: ) self._output = PipeOutput(self._ffmpeg) - # configure picamera2 with H.264 encoder self._picam = Picamera2() config = self._picam.create_video_configuration( main={"size": (1280, 720)}, @@ -107,19 +118,18 @@ class Camera: self._picam.configure(config) self._picam.set_controls( { - "Brightness": 0.1, # -1.0 to 1.0, default 0.0 - "Contrast": 1.1, # 0.0 to 32.0, default 1.0 - "Saturation": 1.1, # 0.0 to 32.0, default 1.0 - "Sharpness": 1.0, # 0.0 to 16.0, default 1.0 - "AwbEnable": True, # auto white balance - "AeEnable": True, # auto exposure + "Brightness": 0.1, + "Contrast": 1.1, + "Saturation": 1.1, + "Sharpness": 1.0, + "AwbEnable": True, + "AeEnable": True, } ) - self._encoder = H264Encoder(bitrate=BITRATE) + self._stream_encoder = H264Encoder(bitrate=BITRATE) 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._ready_event.clear() self._watch_thread = threading.Thread(target=self._watch_playlist, daemon=True) @@ -147,6 +157,11 @@ class Camera: def stop(self) -> None: if not self.running: return + + # stop any active recording first + if self.recording: + self.stop_recording() + self._stop_event.set() self._ready_event.set() # unblock any waiters @@ -174,6 +189,170 @@ class Camera: self.running = False 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 def hls_dir(self) -> Path: return HLS_DIR diff --git a/src/database.py b/src/database.py index f51e710..492926a 100644 --- a/src/database.py +++ b/src/database.py @@ -46,6 +46,7 @@ class CameraStatus(Base): class CameraEvent(Base): __tablename__ = "camera_events" + id: Mapped[int] = mapped_column(Integer, primary_key=True) action: Mapped[str] = mapped_column(String(10), nullable=False) # 'start' | 'stop' ip_address: Mapped[str] = mapped_column(String(45), nullable=False) @@ -73,3 +74,44 @@ class CameraEvent(Base): .scalars() .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() + ) diff --git a/src/templates/index.html b/src/templates/index.html index 399d717..ac7525f 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -44,6 +44,24 @@ 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 { width: 100%; height: 100%; @@ -54,6 +72,10 @@ #placeholder { color: #555; font-size: 0.9rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; } .controls { @@ -70,7 +92,7 @@ border: none; border-radius: 6px; cursor: pointer; - transition: opacity 0.2s; + transition: opacity 0.2s, background 0.2s; } button:disabled { @@ -88,11 +110,128 @@ 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 { font-size: 0.85rem; color: #888; 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; + } @@ -101,71 +240,165 @@
-
Checking stream...
-

- +
+

Recordings

+
+

No recordings yet.

+
+
+ diff --git a/tests/test_camera.py b/tests/test_camera.py index 89fedba..f8e0463 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -17,6 +17,9 @@ def client() -> Generator[FlaskClient, Any, Any]: db.drop_all() +# ── Stream tests ───────────────────────────────────────────────────────────── + + def test_heartbeat_status_code(client: FlaskClient) -> None: response = client.get("/heartbeat") 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") -def test_double_start_is_idempotent(client): +def test_double_start_is_idempotent(client: FlaskClient) -> None: client.post("/camera/start") res = client.post("/camera/start") assert res.get_json()["status"] == "already_running" @@ -48,9 +51,19 @@ def test_camera_status(client: FlaskClient) -> None: assert "running" in data assert "ready" 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/stop") res = client.get("/camera/log") @@ -72,3 +85,81 @@ def test_index_page(client: FlaskClient) -> None: response = client.get("/") assert response.status_code == 200 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