Adding a status db to better track camera status. (#12)
CI / black (push) Successful in 1m25s
CI / ruff (push) Failing after 1m17s
CI / pytest (push) Successful in 1m22s
Dependency update / dependency-update (push) Successful in 1m42s
CI / mypy (push) Failing after 1m25s

Reviewed-on: #12
Co-authored-by: Andrew Kettel <andrew.kettel@gmail.com>
Co-committed-by: Andrew Kettel <andrew.kettel@gmail.com>
This commit was merged in pull request #12.
This commit is contained in:
2026-04-25 09:07:11 -07:00
committed by letteka
parent 76d9ace2b2
commit f5c30e261c
6 changed files with 378 additions and 18 deletions
+78 -7
View File
@@ -1,18 +1,55 @@
import logging
from datetime import UTC, datetime
from flask import Flask, Response, abort, jsonify, render_template, send_from_directory
from flask import (
Flask,
Response,
abort,
jsonify,
render_template,
request,
send_from_directory,
)
from werkzeug.middleware.proxy_fix import ProxyFix
from src.camera import camera
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=2, x_proto=2, x_host=2) # type: ignore[method-assign]
from src.database import db, CameraStatus, CameraEvent
logging.basicConfig(level=logging.WARNING)
def create_app() -> Flask:
flask_app = Flask(__name__, template_folder="templates")
flask_app.wsgi_app = ProxyFix( # type: ignore[assignment, 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:
# was running before restart — mark as stopped
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()
)
@app.get("/heartbeat")
def heartbeat() -> tuple[Response, int]:
return (
@@ -33,29 +70,63 @@ def index() -> str:
@app.post("/camera/start")
def camera_start() -> tuple[Response, int]:
status = CameraStatus.get()
if status.running:
return jsonify({"status": "already_running"}), 200
camera.start()
CameraStatus.set_running(True)
CameraEvent.log("start", get_client_ip())
return jsonify({"status": "started"}), 200
@app.post("/camera/stop")
def camera_stop() -> tuple[Response, int]:
status = CameraStatus.get()
if not status.running:
return jsonify({"status": "already_stopped"}), 200
camera.stop()
CameraStatus.set_running(False)
CameraEvent.log("stop", get_client_ip())
return jsonify({"status": "stopped"}), 200
@app.get("/camera/status")
def camera_status() -> tuple[Response, int]:
status = CameraStatus.get()
return (
jsonify(
{
"running": camera.running,
"ready": camera.wait_until_ready(timeout=0),
"running": status.running,
"ready": (
camera.wait_until_ready(timeout=0) if status.running else False
),
"updated_at": status.updated_at.isoformat(),
}
),
200,
)
@app.get("/camera/log")
def camera_log() -> tuple:
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>")
def hls_segment(filename: str) -> Response:
hls_dir = camera.hls_dir
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from datetime import datetime, timezone
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class CameraStatus(db.Model):
__tablename__ = "camera_status"
id: db.Mapped[int] = db.mapped_column(db.Integer, primary_key=True)
running: db.Mapped[bool] = db.mapped_column(
db.Boolean, nullable=False, default=False
)
updated_at: db.Mapped[datetime] = db.mapped_column(
db.DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.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(timezone.utc)
db.session.commit()
return status
class CameraEvent(db.Model):
__tablename__ = "camera_events"
id: db.Mapped[int] = db.mapped_column(db.Integer, primary_key=True)
action: db.Mapped[str] = db.mapped_column(
db.String(10), nullable=False
) # 'start' | 'stop'
ip_address: db.Mapped[str] = db.mapped_column(db.String(45), nullable=False)
timestamp: db.Mapped[datetime] = db.mapped_column(
db.DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.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 (
db.session.execute(
db.select(CameraEvent)
.order_by(CameraEvent.timestamp.desc())
.limit(limit)
)
.scalars()
.all()
)