Compare commits

..

1 Commits

Author SHA1 Message Date
letteka a778aaa57c modifying create pr to make the payload in python
CI / black (pull_request) Successful in 1m18s
CI / ruff (pull_request) Successful in 1m17s
CI / mypy (pull_request) Successful in 1m25s
CI / pytest (pull_request) Successful in 1m20s
2026-03-26 16:10:59 -07:00
9 changed files with 919 additions and 1917 deletions
-3
View File
@@ -11,6 +11,3 @@ venv/
coverage.xml coverage.xml
.coverage .coverage
htmlcov/ htmlcov/
birdcam.db
.vscode/
.ruff_cache/
+74 -87
View File
@@ -1,106 +1,93 @@
# BirdCam: A Flask-Based Camera Monitoring System # birdcam2
A lightweight, Python-based system for monitoring and managing Raspberry Pi cameras using Flask, SQLAlchemy, and modern Python best practices.
## 🚀 Overview
BirdCam is a simple yet robust application designed to monitor camera status and log events in real time. Built with Flask and SQLAlchemy, it provides: ## Getting started
- Real-time camera status tracking (`running` state).
- Event logging for camera actions (`start`, `stop`) with IP addresses and timestamps.
- A clean, maintainable architecture using modern Python 3.12 features and type hints.
It's ideal for hobbyists, developers, or IoT projects where you need to track camera activity from a central dashboard or script. To make it easy for you to get started with GitLab, here's a list of recommended next steps.
## 🔧 Key Features Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
- ✅ Real-time camera status (e.g., whether a camera is running or not). ## Add your files
- ✅ Event logging with full metadata (action, IP address, timestamp).
- ✅ Automatic creation of status records if missing.
- ✅ Type-safe, well-documented code with full type hints.
- ✅ Modern Python practices:
- Uses Python 3.12 with `__future__.annotations`.
- Leverages `dataclasses`-like patterns with `Mapped` and `mapped_column`.
- Includes comprehensive linting via **Black**, **Ruff**, and **Mypy**.
- ✅ Automated test coverage with `pytest` and `pytest-cov`.
## 📦 Dependencies * [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
| Package | Version | Purpose | ```
|----------------------|---------------|------------------------------------------| cd existing_repo
| `Flask` | `^3.0` | Web framework for serving the app | git remote add origin https://gitlab.com/akettel/birdcam2.git
| `Flask-SQLAlchemy` | `^3.1.1` | ORM for database interactions | git branch -M main
| `Pillow` | `^12.1.1` | Image processing (for camera previews) | git push -uf origin main
| `Gunicorn` | `^25.1.0` | Production WSGI server |
| `Pytest` | `^9.0.3` | Testing framework |
| `Pytest-Cov` | `^7.1.0` | Test coverage reporting |
| `Black` / `Ruff` | `^26.0` / `^0.4` | Code formatting and linting |
| `Mypy` | `^1.10` | Static type checking |
> ✅ All dependencies are pinned for stability and compatibility.
## 🛠️ Setup & Installation
1. Clone the repository:
```bash
git clone https://gitea.letteka.com/letteka/birdcam.git
cd birdcam
``` ```
2. Install dependencies: ## Integrate with your tools
```bash
pip install -e .
```
3. Run the app: * [Set up project integrations](https://gitlab.com/akettel/birdcam2/-/settings/integrations)
```bash
gunicorn -w 1 -b 0.0.0.0:5000 app:app
```
Or use Flasks built-in dev server: ## Collaborate with your team
```bash
flask run
```
## 📝 How It Works * [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
### Camera Status ## Test and Deploy
- The `CameraStatus` model tracks a single cameras running state.
- On startup, it ensures a status record exists (ID = 1).
- The `get()` and `set_running()` methods allow safe access and updates.
### Camera Events Use the built-in continuous integration in GitLab.
- Every time a camera action occurs (`start` or `stop`), an event is logged.
- Events include:
- Action type (`start` / `stop`)
- Client IP address
- Timestamp with timezone support
- The `recent()` method allows retrieval of the latest events (e.g., for logging or dashboard display).
## 🧪 Testing * [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
The project includes full test coverage using `pytest` with coverage reporting: ***
```bash # Editing this README
pytest --cov=src --cov-report=term-missing
```
Tests are located in the tests/ directory and cover: When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
- Status retrieval and updates.
- Event logging and retrieval. ## Suggestions for a good README
- Edge cases (e.g., missing status records).
## 📝 Development Workflow Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
✅ Code is formatted with Black and linted with Ruff.
✅ Static type checking is enforced using Mypy. ## Name
✅ All code uses consistent naming and formatting. Choose a self-explaining name for your project.
✅ All public APIs are documented with docstrings.
## ⚙️ Future Improvements ## Description
- Add support for multiple cameras. Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
- Integrate with a web dashboard (e.g., using Vue.js or Streamlit).
- Add camera preview functionality via HTTP endpoints. ## Badges
- Support for MQTT or other event-driven systems. On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
- Add user authentication or role-based access.
## 📚 License ## Visuals
This project is open-source and available under the MIT License. Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## 🙌 Contributing
Contributions are welcome! Please open an issue or submit a pull request with clear descriptions and tests. ## Installation
📬 Contact: Andrew Kettel andrew.kettel@gmail.com Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
Generated
+762 -980
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -11,16 +11,15 @@ python = "^3.12"
flask = "^3.0" flask = "^3.0"
pillow = "^12.1.1" pillow = "^12.1.1"
gunicorn = "^25.1.0" gunicorn = "^25.1.0"
flask-sqlalchemy = "^3.1.1"
[tool.poetry.group.pi.dependencies] [tool.poetry.group.pi.dependencies]
picamera2 = "^0.3" picamera2 = "^0.3"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^9.0.3" pytest = "^8.0"
pytest-cov = "^7.1.0" pytest-cov = "^5.0"
pytest-flask = "^1.3" pytest-flask = "^1.3"
black = "^26.5.0" black = "^26.0"
ruff = "^0.4" ruff = "^0.4"
mypy = "^1.10" mypy = "^1.10"
pip-audit = "^2.10.0" pip-audit = "^2.10.0"
+9 -141
View File
@@ -1,60 +1,18 @@
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, Response, abort, jsonify, render_template, send_from_directory
Flask,
Response,
abort,
jsonify,
render_template,
request,
send_from_directory,
)
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from src.camera import RECORDINGS_DIR, camera from src.camera import camera
from src.database import CameraEvent, CameraRecordingEvent, CameraStatus, db
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=2, x_proto=2, x_host=2) # type: ignore[method-assign]
logging.basicConfig(level=logging.WARNING) logging.basicConfig(level=logging.WARNING)
def create_app() -> Flask:
flask_app = Flask(__name__, template_folder="templates")
flask_app.wsgi_app = ProxyFix( # type: ignore[method-assign]
flask_app.wsgi_app, x_for=2, x_proto=2, x_host=2
)
flask_app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///birdcam.db"
flask_app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(flask_app)
with flask_app.app_context():
db.create_all()
# sync camera state with db on startup
status = CameraStatus.get()
if status.running and not camera.running:
CameraStatus.set_running(False)
return flask_app
app = create_app()
def get_client_ip() -> str:
return (
request.headers.get("X-Forwarded-For", request.remote_addr or "unknown")
.split(",")[0]
.strip()
)
# ── Health ───────────────────────────────────────────────────────────────────
@app.get("/heartbeat") @app.get("/heartbeat")
def heartbeat() -> tuple[Response, int]: def heartbeat() -> tuple[Response, int]:
return ( return (
@@ -68,85 +26,43 @@ 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()
if status.running:
return jsonify({"status": "already_running"}), 200
camera.start() camera.start()
CameraStatus.set_running(True)
CameraEvent.log("start", get_client_ip())
return jsonify({"status": "started"}), 200 return jsonify({"status": "started"}), 200
@app.post("/camera/stop") @app.post("/camera/stop")
def camera_stop() -> tuple[Response, int]: def camera_stop() -> tuple[Response, int]:
status = CameraStatus.get()
if not status.running:
return jsonify({"status": "already_stopped"}), 200
camera.stop() camera.stop()
CameraStatus.set_running(False)
CameraEvent.log("stop", get_client_ip())
return jsonify({"status": "stopped"}), 200 return jsonify({"status": "stopped"}), 200
@app.get("/camera/status") @app.get("/camera/status")
def camera_status() -> tuple[Response, int]: def camera_status() -> tuple[Response, int]:
status = CameraStatus.get()
rec = camera.recording_status()
return ( return (
jsonify( jsonify(
{ {
"running": status.running, "running": camera.running,
"ready": ( "ready": camera.wait_until_ready(timeout=0),
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, 200,
) )
@app.get("/camera/log")
def camera_log() -> tuple[Response, int]:
events = CameraEvent.recent(limit=50)
return (
jsonify(
[
{
"action": e.action,
"ip_address": e.ip_address,
"timestamp": e.timestamp.isoformat(),
}
for e in events
]
),
200,
)
@app.get("/camera/hls/<path:filename>") @app.get("/camera/hls/<path:filename>")
def hls_segment(filename: str) -> Response: def hls_segment(filename: str) -> Response:
hls_dir = camera.hls_dir hls_dir = camera.hls_dir
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"):
@@ -157,53 +73,5 @@ 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)
+15 -194
View File
@@ -1,12 +1,9 @@
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__)
@@ -22,7 +19,6 @@ 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
@@ -51,21 +47,13 @@ class Camera:
def __init__(self) -> None: def __init__(self) -> None:
self._picam: Picamera2 | None = None self._picam: Picamera2 | None = None
self._stream_encoder: H264Encoder | None = None self._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:
@@ -77,7 +65,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 (picamera2 not available)") logger.info("Mock camera started")
self.running = True self.running = True
return return
@@ -87,11 +75,11 @@ class Camera:
"-loglevel", "-loglevel",
"warning", "warning",
"-f", "-f",
"h264", "h264", # input is raw H.264
"-i", "-i",
"pipe:0", "pipe:0", # read from stdin
"-c:v", "-c:v",
"copy", "copy", # no re-encoding — pass through directly
"-hls_time", "-hls_time",
str(SEGMENT_DURATION), str(SEGMENT_DURATION),
"-hls_list_size", "-hls_list_size",
@@ -111,6 +99,7 @@ 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)},
@@ -118,18 +107,19 @@ class Camera:
self._picam.configure(config) self._picam.configure(config)
self._picam.set_controls( self._picam.set_controls(
{ {
"Brightness": 0.1, "Brightness": 0.1, # -1.0 to 1.0, default 0.0
"Contrast": 1.1, "Contrast": 1.1, # 0.0 to 32.0, default 1.0
"Saturation": 1.1, "Saturation": 1.1, # 0.0 to 32.0, default 1.0
"Sharpness": 1.0, "Sharpness": 1.0, # 0.0 to 16.0, default 1.0
"AwbEnable": True, "AwbEnable": True, # auto white balance
"AeEnable": True, "AeEnable": True, # auto exposure
} }
) )
self._stream_encoder = H264Encoder(bitrate=BITRATE) self._encoder = H264Encoder(bitrate=BITRATE)
buffered = io.BufferedWriter(self._output) buffered = io.BufferedWriter(self._output)
self._picam.start_recording(self._stream_encoder, FileOutput(buffered)) self._picam.start_recording(self._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)
@@ -157,11 +147,6 @@ 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
@@ -189,170 +174,6 @@ 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
-117
View File
@@ -1,117 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
db: SQLAlchemy = SQLAlchemy(model_class=Base)
class CameraStatus(Base):
__tablename__ = "camera_status"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
running: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
)
@staticmethod
def get() -> CameraStatus:
"""Get the single status row, creating it if it doesn't exist."""
status = db.session.get(CameraStatus, 1)
if status is None:
status = CameraStatus(id=1, running=False)
db.session.add(status)
db.session.commit()
return status
@staticmethod
def set_running(running: bool) -> CameraStatus:
status = CameraStatus.get()
status.running = running
status.updated_at = datetime.now(UTC)
db.session.commit()
return status
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)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
)
@staticmethod
def log(action: str, ip_address: str) -> CameraEvent:
event = CameraEvent(action=action, ip_address=ip_address)
db.session.add(event)
db.session.commit()
return event
@staticmethod
def recent(limit: int = 50) -> list[CameraEvent]:
return list(
db.session.execute(
db.select(CameraEvent)
.order_by(CameraEvent.timestamp.desc())
.limit(limit)
)
.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()
)
+41 -262
View File
@@ -44,24 +44,6 @@
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%;
@@ -72,10 +54,6 @@
#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 {
@@ -92,7 +70,7 @@
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: opacity 0.2s, background 0.2s; transition: opacity 0.2s;
} }
button:disabled { button:disabled {
@@ -110,128 +88,11 @@
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>
@@ -240,165 +101,71 @@
<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>
<div id="recordings-section"> <!-- hls.js from CDN -->
<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) + ' &nbsp;&middot;&nbsp; ' + 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 Stream"; stop.textContent = "Stop";
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 Stream"; start.textContent = "Start";
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) { function showOffline(message = "Stream is offline") {
preview.classList.add("is-recording");
} else {
preview.classList.remove("is-recording");
}
}
// ── 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 || "Stream is offline"; placeholderText.textContent = message;
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()) {
@@ -412,6 +179,7 @@
}); });
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);
} }
@@ -433,14 +201,18 @@
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();
} }
async function waitForReady(maxAttempts) { function setStatus(msg) {
maxAttempts = maxAttempts || 40; statusEl.textContent = msg;
}
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;
@@ -449,10 +221,9 @@
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);
@@ -488,10 +259,15 @@
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) { await attachToStream(); return; } if (current.running || current.ready) {
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;
@@ -499,6 +275,7 @@
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();
@@ -524,12 +301,14 @@
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) startOfflinePoll(); if (!data.running && !data.ready) {
startOfflinePoll();
}
})(); })();
</script> </script>
</body> </body>
+5 -119
View File
@@ -4,20 +4,14 @@ from typing import Any
import pytest import pytest
from flask.testing import FlaskClient from flask.testing import FlaskClient
from src.app import app, db from src.app import app
@pytest.fixture @pytest.fixture
def client() -> Generator[FlaskClient, Any, Any]: def client() -> Generator[FlaskClient, Any, Any]:
app.config["TESTING"] = True app.config["TESTING"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" with app.test_client() as client:
with app.app_context(): yield client
db.create_all()
yield app.test_client()
db.drop_all()
# ── Stream tests ─────────────────────────────────────────────────────────────
def test_heartbeat_status_code(client: FlaskClient) -> None: def test_heartbeat_status_code(client: FlaskClient) -> None:
@@ -28,20 +22,14 @@ def test_heartbeat_status_code(client: FlaskClient) -> None:
def test_camera_start(client: FlaskClient) -> None: def test_camera_start(client: FlaskClient) -> None:
response = client.post("/camera/start") response = client.post("/camera/start")
assert response.status_code == 200 assert response.status_code == 200
assert response.get_json()["status"] in ("started", "already_running") assert response.get_json()["status"] == "started"
def test_camera_stop(client: FlaskClient) -> None: def test_camera_stop(client: FlaskClient) -> None:
client.post("/camera/start") client.post("/camera/start")
response = client.post("/camera/stop") response = client.post("/camera/stop")
assert response.status_code == 200 assert response.status_code == 200
assert response.get_json()["status"] in ("stopped", "already_stopped") assert response.get_json()["status"] == "stopped"
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"
def test_camera_status(client: FlaskClient) -> None: def test_camera_status(client: FlaskClient) -> None:
@@ -50,30 +38,6 @@ def test_camera_status(client: FlaskClient) -> None:
data = response.get_json() data = response.get_json()
assert "running" in data assert "running" in data
assert "ready" 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_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")
assert res.status_code == 200
events = res.get_json()
assert len(events) == 2
assert events[0]["action"] == "stop" # most recent first
assert events[1]["action"] == "start"
assert "ip_address" in events[0]
assert "timestamp" in events[0]
def test_hls_404_when_not_running(client: FlaskClient) -> None: def test_hls_404_when_not_running(client: FlaskClient) -> None:
@@ -85,81 +49,3 @@ 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