410 lines
12 KiB
Python
410 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Edge Jetson production counter dashboard.
|
|
Reads jetson_counter.db + current_batch.json from jetson-counter stack.
|
|
Default port 5000 (replaces frigate-counter dashboard role).
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import csv
|
|
import time
|
|
from io import StringIO
|
|
from datetime import datetime, timedelta
|
|
|
|
from flask import Flask, render_template, jsonify, request, Response
|
|
from werkzeug.serving import WSGIRequestHandler
|
|
|
|
from dotenv import load_dotenv
|
|
load_dotenv()
|
|
|
|
|
|
app = Flask(__name__, template_folder="templates")
|
|
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
|
|
|
|
_DEFAULT_DIR = "/opt/jetson-counter"
|
|
DB_PATH = os.getenv("DB_PATH", f"{_DEFAULT_DIR}/jetson_counter.db")
|
|
CURRENT_BATCH_PATH = os.getenv("STATE_FILE", os.getenv("CURRENT_BATCH_PATH", f"{_DEFAULT_DIR}/current_batch.json"))
|
|
CUTOFF_TIME = os.getenv("CUTOFF_TIME", os.getenv("DAILY_CUTOFF_TIME", "20:00"))
|
|
|
|
LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg")
|
|
|
|
@app.route("/api/live-video")
|
|
def api_live_video():
|
|
def generate():
|
|
while True:
|
|
try:
|
|
with open(LIVE_STREAM_FRAME_PATH, "rb") as f:
|
|
jpeg = f.read()
|
|
yield (b"--frame\r\n"
|
|
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n")
|
|
except FileNotFoundError:
|
|
time.sleep(1.0)
|
|
continue
|
|
except Exception:
|
|
time.sleep(0.5)
|
|
continue
|
|
time.sleep(0.05)
|
|
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
|
|
|
|
|
def get_db():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
|
|
if dt is None:
|
|
dt = datetime.now()
|
|
cutoff = datetime.strptime(cutoff_str, "%H:%M").time()
|
|
if dt.time() < cutoff:
|
|
return dt.date().isoformat()
|
|
return (dt.date() + timedelta(days=1)).isoformat()
|
|
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return render_template("dashboard.html")
|
|
|
|
|
|
@app.route("/api/current-batch")
|
|
def api_current_batch():
|
|
try:
|
|
with open(CURRENT_BATCH_PATH, "r") as f:
|
|
data = json.load(f)
|
|
return jsonify(
|
|
{
|
|
"success": True,
|
|
"counting_date": data.get("counting_date"),
|
|
"batch_number": data.get("batch_number"),
|
|
"count": data.get("count", 0),
|
|
"start_time": data.get("start_time"),
|
|
"last_detection_time": data.get("last_detection_time"),
|
|
}
|
|
)
|
|
except FileNotFoundError:
|
|
return jsonify(
|
|
{
|
|
"success": False,
|
|
"error": "No active batch",
|
|
"count": 0,
|
|
"batch_number": None,
|
|
"counting_date": None,
|
|
}
|
|
), 200
|
|
except Exception as e:
|
|
return jsonify(
|
|
{
|
|
"success": False,
|
|
"error": str(e),
|
|
"count": 0,
|
|
"batch_number": None,
|
|
"counting_date": None,
|
|
}
|
|
), 500
|
|
|
|
|
|
@app.route("/api/previous-batch")
|
|
def api_previous_batch():
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT counting_date, batch_number, count, start_time, end_time,
|
|
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
|
FROM batches
|
|
ORDER BY end_time DESC
|
|
LIMIT 1
|
|
"""
|
|
)
|
|
row = cur.fetchone()
|
|
conn.close()
|
|
if row:
|
|
return jsonify(
|
|
{
|
|
"success": True,
|
|
"date": row["counting_date"],
|
|
"batch_number": row["batch_number"],
|
|
"count": row["count"],
|
|
"start_time": row["start_time"],
|
|
"end_time": row["end_time"],
|
|
"duration_minutes": row["duration_minutes"],
|
|
}
|
|
)
|
|
return jsonify({"success": False, "error": "No previous batch"}), 200
|
|
|
|
|
|
@app.route("/api/summary")
|
|
def api_summary():
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
today = get_counting_date()
|
|
cur.execute(
|
|
"""
|
|
SELECT COALESCE(total_count, 0) as total_count,
|
|
COALESCE(total_batches, 0) as total_batches
|
|
FROM daily_summaries
|
|
WHERE counting_date = ?
|
|
""",
|
|
(today,),
|
|
)
|
|
today_row = cur.fetchone()
|
|
yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat()
|
|
cur.execute(
|
|
"""
|
|
SELECT COALESCE(total_count, 0) as total_count,
|
|
COALESCE(total_batches, 0) as total_batches
|
|
FROM daily_summaries
|
|
WHERE counting_date = ?
|
|
""",
|
|
(yesterday,),
|
|
)
|
|
yesterday_row = cur.fetchone()
|
|
cur.execute(
|
|
"""
|
|
SELECT COALESCE(SUM(total_count), 0) as grand_total,
|
|
COALESCE(SUM(total_batches), 0) as grand_batches,
|
|
COUNT(DISTINCT counting_date) as total_days
|
|
FROM daily_summaries
|
|
"""
|
|
)
|
|
all_time = cur.fetchone()
|
|
cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_summaries")
|
|
avg = cur.fetchone()
|
|
cur.execute(
|
|
"""
|
|
SELECT counting_date, total_count
|
|
FROM daily_summaries
|
|
ORDER BY total_count DESC
|
|
LIMIT 1
|
|
"""
|
|
)
|
|
best = cur.fetchone()
|
|
conn.close()
|
|
return jsonify(
|
|
{
|
|
"today": {
|
|
"date": today,
|
|
"total_count": today_row["total_count"] if today_row else 0,
|
|
"total_batches": today_row["total_batches"] if today_row else 0,
|
|
},
|
|
"yesterday": {
|
|
"date": yesterday,
|
|
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
|
|
"total_batches": yesterday_row["total_batches"] if yesterday_row else 0,
|
|
},
|
|
"all_time": {
|
|
"grand_total": all_time["grand_total"],
|
|
"grand_batches": all_time["grand_batches"],
|
|
"total_days": all_time["total_days"],
|
|
},
|
|
"average_per_day": avg["avg_per_day"] or 0,
|
|
"best_day": {
|
|
"date": best["counting_date"] if best else None,
|
|
"count": best["total_count"] if best else 0,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@app.route("/api/daily-data")
|
|
def api_daily_data():
|
|
days = request.args.get("days", 30, type=int)
|
|
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT counting_date, total_count, total_batches,
|
|
ROUND(CAST(total_count AS FLOAT) / total_batches, 1) as avg_per_batch
|
|
FROM daily_summaries
|
|
WHERE counting_date >= ?
|
|
ORDER BY counting_date ASC
|
|
""",
|
|
(date_from,),
|
|
)
|
|
daily_data = [
|
|
{
|
|
"date": row["counting_date"],
|
|
"total_count": row["total_count"],
|
|
"total_batches": row["total_batches"],
|
|
"avg_per_batch": row["avg_per_batch"] or 0,
|
|
}
|
|
for row in cur.fetchall()
|
|
]
|
|
conn.close()
|
|
return jsonify(daily_data)
|
|
|
|
|
|
@app.route("/api/day-detail/<date>")
|
|
def api_day_detail(date):
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT batch_number, count, start_time, end_time,
|
|
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
|
FROM batches
|
|
WHERE counting_date = ?
|
|
ORDER BY batch_number ASC
|
|
""",
|
|
(date,),
|
|
)
|
|
batches = []
|
|
total_duration = 0
|
|
for row in cur.fetchall():
|
|
duration = row["duration_minutes"] or 0
|
|
total_duration += duration
|
|
batches.append(
|
|
{
|
|
"batch_number": row["batch_number"],
|
|
"count": row["count"],
|
|
"start_time": row["start_time"],
|
|
"end_time": row["end_time"],
|
|
"duration_minutes": duration,
|
|
}
|
|
)
|
|
cur.execute(
|
|
"""
|
|
SELECT total_count, total_batches
|
|
FROM daily_summaries
|
|
WHERE counting_date = ?
|
|
""",
|
|
(date,),
|
|
)
|
|
summary = cur.fetchone()
|
|
conn.close()
|
|
return jsonify(
|
|
{
|
|
"date": date,
|
|
"total_count": summary["total_count"] if summary else 0,
|
|
"total_batches": summary["total_batches"] if summary else 0,
|
|
"total_duration_minutes": round(total_duration, 1),
|
|
"avg_duration_minutes": round(total_duration / len(batches), 1) if batches else 0,
|
|
"batches": batches,
|
|
}
|
|
)
|
|
|
|
|
|
@app.route("/api/recent-batches")
|
|
def api_recent_batches():
|
|
limit = request.args.get("limit", 10, type=int)
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT counting_date, batch_number, count, start_time, end_time,
|
|
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
|
FROM batches
|
|
ORDER BY end_time DESC
|
|
LIMIT ?
|
|
""",
|
|
(limit,),
|
|
)
|
|
batches = [
|
|
{
|
|
"date": row["counting_date"],
|
|
"batch_number": row["batch_number"],
|
|
"count": row["count"],
|
|
"start_time": row["start_time"],
|
|
"end_time": row["end_time"],
|
|
"duration_minutes": row["duration_minutes"] or 0,
|
|
}
|
|
for row in cur.fetchall()
|
|
]
|
|
conn.close()
|
|
return jsonify(batches)
|
|
|
|
|
|
@app.route("/api/available-dates")
|
|
def api_available_dates():
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT counting_date, total_count, total_batches
|
|
FROM daily_summaries
|
|
ORDER BY counting_date DESC
|
|
"""
|
|
)
|
|
dates = [
|
|
{
|
|
"date": row["counting_date"],
|
|
"total_count": row["total_count"],
|
|
"total_batches": row["total_batches"],
|
|
}
|
|
for row in cur.fetchall()
|
|
]
|
|
conn.close()
|
|
return jsonify(dates)
|
|
|
|
|
|
@app.route("/api/export-daily-csv")
|
|
def export_daily_csv():
|
|
days = request.args.get("days", 30, type=int)
|
|
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT counting_date, total_count, total_batches,
|
|
ROUND(CAST(total_count AS FLOAT) / NULLIF(total_batches, 0), 1) as avg_per_batch
|
|
FROM daily_summaries
|
|
WHERE counting_date >= ?
|
|
ORDER BY counting_date ASC
|
|
""",
|
|
(date_from,),
|
|
)
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(["Date", "Total Count", "Total Batches", "Avg per Batch"])
|
|
for row in cur.fetchall():
|
|
writer.writerow([row["counting_date"], row["total_count"], row["total_batches"], row["avg_per_batch"] or 0])
|
|
conn.close()
|
|
filename = f"daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
|
return Response(
|
|
output.getvalue(),
|
|
mimetype="text/csv",
|
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
)
|
|
|
|
|
|
@app.route("/api/export-day-csv/<date>")
|
|
def export_day_csv(date):
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT batch_number, count, start_time, end_time,
|
|
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
|
FROM batches
|
|
WHERE counting_date = ?
|
|
ORDER BY batch_number ASC
|
|
""",
|
|
(date,),
|
|
)
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(["Batch Number", "Count", "Start Time", "End Time", "Duration (min)"])
|
|
for row in cur.fetchall():
|
|
writer.writerow([row["batch_number"], row["count"], row["start_time"], row["end_time"], row["duration_minutes"] or 0])
|
|
conn.close()
|
|
return Response(
|
|
output.getvalue(),
|
|
mimetype="text/csv",
|
|
headers={"Content-Disposition": f"attachment; filename=day_detail_{date}.csv"},
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
WSGIRequestHandler.protocol_version = "HTTP/1.1"
|
|
port = int(os.getenv("DASHBOARD_PORT", "5000"))
|
|
host = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
|
debug = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
|
print(f"Jetson counter dashboard at http://{host}:{port}")
|
|
print(f"DB: {DB_PATH}")
|
|
print(f"State: {CURRENT_BATCH_PATH}")
|
|
app.run(host=host, port=port, debug=debug)
|