Adding a status db to better track camera status. (#12)
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:
+78
-7
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
Reference in New Issue
Block a user