#!/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 logging import os import sqlite3 import time from io import BytesIO from datetime import datetime, timedelta from pathlib import Path from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from flask import Flask, render_template, jsonify, request, Response from werkzeug.serving import WSGIRequestHandler from dotenv import load_dotenv load_dotenv() log = logging.getLogger('werkzeug') log.setLevel(logging.ERROR) 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") SHM_DIR = os.getenv("SHM_DIR", "/dev/shm/bytetrack-counter") SITE_NAME = os.getenv("SITE_NAME", "LIVE") TEMPLATE = os.getenv("DASHBOARD_TEMPLATE", "dashboard.html") UPLOAD_DASHBOARD_URL = os.getenv("UPLOAD_DASHBOARD_URL", "") DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000")) DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0") FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true" @app.route("/api/live-video") def api_live_video(): if not os.path.isfile(LIVE_STREAM_FRAME_PATH): return jsonify({"success": False, "error": "Live stream frame not available yet"}), 503 def generate(): consecutive_fails = 0 MAX_FAILS = 30 while True: try: with open(LIVE_STREAM_FRAME_PATH, "rb") as f: jpeg = f.read() consecutive_fails = 0 yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n") except FileNotFoundError: consecutive_fails += 1 if consecutive_fails >= MAX_FAILS: return time.sleep(1.0) continue except Exception: consecutive_fails += 1 if consecutive_fails >= MAX_FAILS: return time.sleep(0.5) continue time.sleep(0.05) return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame") def _ensure_db(): conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute( """ CREATE TABLE IF NOT EXISTS batches ( id INTEGER PRIMARY KEY AUTOINCREMENT, counting_date TEXT NOT NULL, batch_number INTEGER NOT NULL, camera_name TEXT NOT NULL, object_label TEXT NOT NULL, count INTEGER NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(counting_date, batch_number, camera_name, object_label) ) """ ) cur.execute( """ CREATE TABLE IF NOT EXISTS daily_summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, counting_date TEXT NOT NULL, camera_name TEXT NOT NULL, object_label TEXT NOT NULL, total_count INTEGER NOT NULL DEFAULT 0, total_batches INTEGER NOT NULL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(counting_date, camera_name, object_label) ) """ ) conn.commit() conn.close() _ensure_db() 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(TEMPLATE, site_name=SITE_NAME, upload_dashboard_url=UPLOAD_DASHBOARD_URL) @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(): try: 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 except sqlite3.OperationalError as e: return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/summary") def api_summary(): try: 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, }, } ) except sqlite3.OperationalError as e: return jsonify({"success": False, "error": f"Database unavailable: {e}", "today": {"date": datetime.now().date().isoformat(), "total_count": 0, "total_batches": 0}, "yesterday": {"date": "", "total_count": 0, "total_batches": 0}, "all_time": {"grand_total": 0, "grand_batches": 0, "total_days": 0}, "average_per_day": 0, "best_day": {"date": None, "count": 0}}), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/daily-data") def api_daily_data(): try: 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) except sqlite3.OperationalError as e: return jsonify([]), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/day-detail/") def api_day_detail(date): try: 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, } ) except sqlite3.OperationalError as e: return jsonify({"date": date, "total_count": 0, "total_batches": 0, "total_duration_minutes": 0, "avg_duration_minutes": 0, "batches": [], "error": f"Database unavailable: {e}"}), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/recent-batches") def api_recent_batches(): try: 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) except sqlite3.OperationalError as e: return jsonify([]), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route("/api/available-dates") def api_available_dates(): try: 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) except sqlite3.OperationalError as e: return jsonify([]), 200 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 def _excel_response(wb, filename): output = BytesIO() wb.save(output) output.seek(0) return Response( output.getvalue(), mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) def _style_header(ws, cols): header_font = Font(bold=True, color="FFFFFF", size=11) header_fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid") thin_border = Border( left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin"), ) for col_idx, (col_letter, text) in enumerate(cols, 1): cell = ws.cell(row=1, column=col_idx, value=text) cell.font = header_font cell.fill = header_fill cell.alignment = Alignment(horizontal="center") cell.border = thin_border ws.freeze_panes = "A2" def _auto_width(ws): for col in ws.columns: max_len = 0 col_letter = col[0].column_letter for cell in col: if cell.value is not None: max_len = max(max_len, len(str(cell.value))) ws.column_dimensions[col_letter].width = max_len + 4 @app.route("/api/export-daily-csv") def export_daily_xlsx(): try: 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, 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 counting_date ASC, batch_number ASC """, (date_from,), ) rows = cur.fetchall() conn.close() except sqlite3.OperationalError as e: return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 wb = Workbook() ws = wb.active ws.title = "Batch Details" _style_header(ws, [("A", "Date"), ("B", "Batch #"), ("C", "Count"), ("D", "Start Time"), ("E", "End Time"), ("F", "Duration (min)")]) for r_idx, row in enumerate(rows, 2): ws.cell(row=r_idx, column=1, value=row["counting_date"]) ws.cell(row=r_idx, column=2, value=row["batch_number"]) ws.cell(row=r_idx, column=3, value=row["count"]) ws.cell(row=r_idx, column=4, value=row["start_time"]) ws.cell(row=r_idx, column=5, value=row["end_time"]) ws.cell(row=r_idx, column=6, value=row["duration_minutes"] or 0) _auto_width(ws) filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" return _excel_response(wb, filename) @app.route("/api/export-day-csv/") def export_day_xlsx(date): try: 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,), ) rows = cur.fetchall() conn.close() except sqlite3.OperationalError as e: return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 wb = Workbook() ws = wb.active ws.title = f"Day {date}" _style_header(ws, [("A", "Batch Number"), ("B", "Count"), ("C", "Start Time"), ("D", "End Time"), ("E", "Duration (min)")]) for r_idx, row in enumerate(rows, 2): ws.cell(row=r_idx, column=1, value=row["batch_number"]) ws.cell(row=r_idx, column=2, value=row["count"]) ws.cell(row=r_idx, column=3, value=row["start_time"]) ws.cell(row=r_idx, column=4, value=row["end_time"]) ws.cell(row=r_idx, column=5, value=row["duration_minutes"] or 0) _auto_width(ws) filename = f"{SITE_NAME}_day_detail_{date}.xlsx" return _excel_response(wb, filename) @app.route("/api/reset", methods=["POST"]) def api_reset(): print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Reset API called") try: paths = {CURRENT_BATCH_PATH} sf = os.getenv("STATE_FILE") if sf: paths.add(sf) cb = os.getenv("CURRENT_BATCH_PATH") if cb: paths.add(cb) for p in paths: Path(p).unlink(missing_ok=True) Path(LIVE_STREAM_FRAME_PATH).unlink(missing_ok=True) except Exception as e: print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Reset state file cleanup error: {e}") try: Path(SHM_DIR).mkdir(parents=True, exist_ok=True) Path(f"{SHM_DIR}/.reset").touch() print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Reset done — cleared {len(paths)} state file(s) + live frame + SHM reset flag at {SHM_DIR}/.reset") except Exception as e: print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Reset SHM error: {e}") return jsonify({"success": False, "error": f"SHM reset failed: {e}"}), 500 reset_path = f"{SHM_DIR}/.reset" return jsonify({ "success": True, "message": "State + live frame cleared", "shm_reset": reset_path, "shm_exists": Path(reset_path).exists(), }) if __name__ == "__main__": WSGIRequestHandler.protocol_version = "HTTP/1.1" print(f"Jetson counter dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}") print(f"DB: {DB_PATH}") print(f"State: {CURRENT_BATCH_PATH}") app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)