#!/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/") 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/") 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)