From b3d162450de3bb0c16a1c3c25ed713d406a276db Mon Sep 17 00:00:00 2001 From: dsutanto Date: Tue, 19 May 2026 14:32:46 +0700 Subject: [PATCH] First Commit --- README.md | 6 +- counter_dashboard.py | 462 +++++++++++++++++ counter_dashboard.py.20260506 | 280 +++++++++++ counter_dashboard.py.20260517 | 377 ++++++++++++++ counter_service-test.py | 503 +++++++++++++++++++ counter_service.py | 519 +++++++++++++++++++ counter_service.py.20260514 | 464 +++++++++++++++++ counter_service.py.20260518 | 513 +++++++++++++++++++ counter_service.py.no_reset_timer | 505 +++++++++++++++++++ counter_service.py.workarund | 507 +++++++++++++++++++ docker-compose.yaml | 14 + env.example | 24 + frigate-counter-dashboard.service | 48 ++ frigate-counter.service | 58 +++ frigate_counter.db | Bin 0 -> 147456 bytes frigate_counter.sql | 17 + requirements.txt | 3 + templates/dashboard.html | 796 ++++++++++++++++++++++++++++++ templates/dashboard.html.20260506 | 710 ++++++++++++++++++++++++++ templates/dashboard.html.20260517 | 739 +++++++++++++++++++++++++++ 20 files changed, 6543 insertions(+), 2 deletions(-) create mode 100644 counter_dashboard.py create mode 100644 counter_dashboard.py.20260506 create mode 100644 counter_dashboard.py.20260517 create mode 100644 counter_service-test.py create mode 100644 counter_service.py create mode 100644 counter_service.py.20260514 create mode 100644 counter_service.py.20260518 create mode 100644 counter_service.py.no_reset_timer create mode 100644 counter_service.py.workarund create mode 100644 docker-compose.yaml create mode 100644 env.example create mode 100644 frigate-counter-dashboard.service create mode 100644 frigate-counter.service create mode 100644 frigate_counter.db create mode 100644 frigate_counter.sql create mode 100644 requirements.txt create mode 100644 templates/dashboard.html create mode 100644 templates/dashboard.html.20260506 create mode 100644 templates/dashboard.html.20260517 diff --git a/README.md b/README.md index 8dd89b9..c8f239a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ -# zenai-apc-salatiga - +pip install -r requirements.txt +export CAMERA_NAME=cam_gudang_utara +export FRIGATE_MQTT_HOST=192.168.1.10 +python counter_service.py diff --git a/counter_dashboard.py b/counter_dashboard.py new file mode 100644 index 0000000..9cd60b6 --- /dev/null +++ b/counter_dashboard.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +""" +Frigate Counter Dashboard +A beautiful, interactive Flask web app for viewing daily batch counting data. +""" + +import json +import os +import sqlite3 +import csv +from io import StringIO +from datetime import datetime, timedelta + +from flask import Flask, render_template, jsonify, request, Response +from werkzeug.serving import WSGIRequestHandler + + +app = Flask(__name__, template_folder="templates") +app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "frigate-counter-dashboard") + +DB_PATH = os.getenv("DB_PATH", "frigate_counter.db") +CURRENT_BATCH_PATH = os.getenv("CURRENT_BATCH_PATH", "current_batch.json") +CUTOFF_TIME = os.getenv("CUTOFF_TIME", "17:00") + + +# ------------------------------------------------------------------ # +# Database helpers +# ------------------------------------------------------------------ # +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +# def get_counting_date(dt=None, cutoff_str="17:00"): +def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME): + """Replicate the service logic for determining the counting date.""" + 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() + + +# ------------------------------------------------------------------ # +# Routes +# ------------------------------------------------------------------ # +@app.route("/") +def index(): + """Main dashboard page.""" + return render_template("dashboard.html") + + +@app.route("/api/current-batch") +def api_current_batch(): + """Get real-time data from the current active batch file.""" + 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, + } + ), 404 + except Exception as e: + return jsonify( + { + "success": False, + "error": str(e), + "count": 0, + "batch_number": None, + "counting_date": None, + } + ), 500 + + +@app.route("/api/summary") +def api_summary(): + """Get summary statistics for the dashboard cards.""" + conn = get_db() + cur = conn.cursor() + + # Today's counting date (based on cutoff) + today = get_counting_date() + + # Today's stats + 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's stats + 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() + + # All-time totals + 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() + + # Average per day + cur.execute(""" + SELECT ROUND(AVG(total_count), 1) as avg_per_day + FROM daily_summaries + """) + avg = cur.fetchone() + + # Best day + 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(): + """Get daily data for charts and table.""" + days = request.args.get("days", 30, type=int) + date_from = (datetime.now() - timedelta(days=days)).date().isoformat() + + conn = get_db() + cur = conn.cursor() + + # Daily summaries for chart + 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 = [] + for row in cur.fetchall(): + daily_data.append( + { + "date": row["counting_date"], + "total_count": row["total_count"], + "total_batches": row["total_batches"], + "avg_per_batch": row["avg_per_batch"] or 0, + } + ) + + conn.close() + return jsonify(daily_data) + + +@app.route("/api/day-detail/") +def api_day_detail(date): + """Get detailed batch information for a specific day.""" + conn = get_db() + cur = conn.cursor() + + # Batches for this day + 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, + } + ) + + # Summary for the day + 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(): + """Get the most recent batches across all days.""" + 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 = [] + for row in cur.fetchall(): + batches.append( + { + "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, + } + ) + + conn.close() + return jsonify(batches) + + +@app.route("/api/available-dates") +def api_available_dates(): + """Get list of all available counting 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 = [] + for row in cur.fetchall(): + dates.append( + { + "date": row["counting_date"], + "total_count": row["total_count"], + "total_batches": row["total_batches"], + } + ) + + conn.close() + return jsonify(dates) + + +# ------------------------------------------------------------------ # +# CSV Export Routes +# ------------------------------------------------------------------ # +@app.route("/api/export-daily-csv") +def export_daily_csv(): + """Export daily summary records as 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() + + csv_data = output.getvalue() + output.close() + + filename = f"daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + return Response( + csv_data, + mimetype="text/csv", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Type": "text/csv; charset=utf-8", + }, + ) + + +@app.route("/api/export-day-csv/") +def export_day_csv(date): + """Export batch details for a specific day as CSV.""" + conn = get_db() + cur = conn.cursor() + + # Batches for this day + 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() + + csv_data = output.getvalue() + output.close() + + filename = f"day_detail_{date}.csv" + return Response( + csv_data, + mimetype="text/csv", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Type": "text/csv; charset=utf-8", + }, + ) + + +# ------------------------------------------------------------------ # +# Run +# ------------------------------------------------------------------ # +if __name__ == "__main__": + # Suppress Flask's default request log spam + WSGIRequestHandler.protocol_version = "HTTP/1.1" + + port = int(os.getenv("DASHBOARD_PORT", 80)) + host = os.getenv("DASHBOARD_HOST", "0.0.0.0") + debug = os.getenv("FLASK_DEBUG", "false").lower() == "true" + + print(f"🚀 Dashboard running at http://{host}:{port}") + app.run(host=host, port=port, debug=debug) diff --git a/counter_dashboard.py.20260506 b/counter_dashboard.py.20260506 new file mode 100644 index 0000000..6808e78 --- /dev/null +++ b/counter_dashboard.py.20260506 @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +Frigate Counter Dashboard +A beautiful, interactive Flask web app for viewing daily batch counting data. +""" +import os +import sqlite3 +from datetime import datetime, timedelta + +from flask import Flask, render_template, jsonify, request +from werkzeug.serving import WSGIRequestHandler + + +app = Flask(__name__, template_folder='templates') +app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'frigate-counter-dashboard') + +DB_PATH = os.getenv('DB_PATH', 'frigate_counter.db') + + +# ------------------------------------------------------------------ # +# Database helpers +# ------------------------------------------------------------------ # +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def get_counting_date(dt=None, cutoff_str="08:00"): + """Replicate the service logic for determining the counting date.""" + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(cutoff_str, "%H:%M").time() + print(f"DT: {dt.time()}, CO: {cutoff}") + if dt.time() >= cutoff: + print(f"Return Current day: {dt.date()}") + return dt.date().isoformat() + print(f"Return -1 day: {dt.date() - timedelta(days=1)}") + return (dt.date() - timedelta(days=1)).isoformat() + + +# ------------------------------------------------------------------ # +# Routes +# ------------------------------------------------------------------ # +@app.route('/') +def index(): + """Main dashboard page.""" + return render_template('dashboard.html') + + +@app.route('/api/summary') +def api_summary(): + """Get summary statistics for the dashboard cards.""" + conn = get_db() + cur = conn.cursor() + + # Today's counting date (based on cutoff) + today = get_counting_date() + + # Today's stats + 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's stats + 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() + + # All-time totals + 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() + + # Average per day + cur.execute(""" + SELECT ROUND(AVG(total_count), 1) as avg_per_day + FROM daily_summaries + """) + avg = cur.fetchone() + + # Best day + 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(): + """Get daily data for charts and table.""" + days = request.args.get('days', 30, type=int) + date_from = (datetime.now() - timedelta(days=days)).date().isoformat() + + conn = get_db() + cur = conn.cursor() + + # Daily summaries for chart + 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 = [] + for row in cur.fetchall(): + daily_data.append({ + 'date': row['counting_date'], + 'total_count': row['total_count'], + 'total_batches': row['total_batches'], + 'avg_per_batch': row['avg_per_batch'] or 0, + }) + + conn.close() + return jsonify(daily_data) + + +@app.route('/api/day-detail/') +def api_day_detail(date): + """Get detailed batch information for a specific day.""" + conn = get_db() + cur = conn.cursor() + + # Batches for this day + 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, + }) + + # Summary for the day + 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(): + """Get the most recent batches across all days.""" + 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 = [] + for row in cur.fetchall(): + batches.append({ + '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, + }) + + conn.close() + return jsonify(batches) + + +@app.route('/api/available-dates') +def api_available_dates(): + """Get list of all available counting 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 = [] + for row in cur.fetchall(): + dates.append({ + 'date': row['counting_date'], + 'total_count': row['total_count'], + 'total_batches': row['total_batches'], + }) + + conn.close() + return jsonify(dates) + + +# ------------------------------------------------------------------ # +# Run +# ------------------------------------------------------------------ # +if __name__ == '__main__': + # Suppress Flask's default request log spam + WSGIRequestHandler.protocol_version = "HTTP/1.1" + + port = int(os.getenv('DASHBOARD_PORT', 80)) + host = os.getenv('DASHBOARD_HOST', '0.0.0.0') + debug = os.getenv('FLASK_DEBUG', 'false').lower() == 'true' + + print(f"🚀 Dashboard running at http://{host}:{port}") + app.run(host=host, port=port, debug=debug) diff --git a/counter_dashboard.py.20260517 b/counter_dashboard.py.20260517 new file mode 100644 index 0000000..d6c4940 --- /dev/null +++ b/counter_dashboard.py.20260517 @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +""" +Frigate Counter Dashboard +A beautiful, interactive Flask web app for viewing daily batch counting data. +""" +import os +import sqlite3 +import csv +from io import StringIO +from datetime import datetime, timedelta + +from flask import Flask, render_template, jsonify, request, Response +from werkzeug.serving import WSGIRequestHandler + + +app = Flask(__name__, template_folder='templates') +app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'frigate-counter-dashboard') + +DB_PATH = os.getenv('DB_PATH', 'frigate_counter.db') + + +# ------------------------------------------------------------------ # +# Database helpers +# ------------------------------------------------------------------ # +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def get_counting_date(dt=None, cutoff_str="20:00"): + """Replicate the service logic for determining the counting date.""" + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(cutoff_str, "%H:%M").time() + #if dt.time() >= cutoff: + if dt.time() < cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + #return (dt.date() - timedelta(days=1)).isoformat() + + +# ------------------------------------------------------------------ # +# Routes +# ------------------------------------------------------------------ # +@app.route('/') +def index(): + """Main dashboard page.""" + return render_template('dashboard.html') + + +@app.route('/api/summary') +def api_summary(): + """Get summary statistics for the dashboard cards.""" + conn = get_db() + cur = conn.cursor() + + # Today's counting date (based on cutoff) + today = get_counting_date() + + # Today's stats + 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's stats + 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() + + # All-time totals + 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() + + # Average per day + cur.execute(""" + SELECT ROUND(AVG(total_count), 1) as avg_per_day + FROM daily_summaries + """) + avg = cur.fetchone() + + # Best day + 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(): + """Get daily data for charts and table.""" + days = request.args.get('days', 30, type=int) + date_from = (datetime.now() - timedelta(days=days)).date().isoformat() + + conn = get_db() + cur = conn.cursor() + + # Daily summaries for chart + 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 = [] + for row in cur.fetchall(): + daily_data.append({ + 'date': row['counting_date'], + 'total_count': row['total_count'], + 'total_batches': row['total_batches'], + 'avg_per_batch': row['avg_per_batch'] or 0, + }) + + conn.close() + return jsonify(daily_data) + + +@app.route('/api/day-detail/') +def api_day_detail(date): + """Get detailed batch information for a specific day.""" + conn = get_db() + cur = conn.cursor() + + # Batches for this day + 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, + }) + + # Summary for the day + 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(): + """Get the most recent batches across all days.""" + 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 = [] + for row in cur.fetchall(): + batches.append({ + '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, + }) + + conn.close() + return jsonify(batches) + + +@app.route('/api/available-dates') +def api_available_dates(): + """Get list of all available counting 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 = [] + for row in cur.fetchall(): + dates.append({ + 'date': row['counting_date'], + 'total_count': row['total_count'], + 'total_batches': row['total_batches'], + }) + + conn.close() + return jsonify(dates) + + +# ------------------------------------------------------------------ # +# CSV Export Routes +# ------------------------------------------------------------------ # +@app.route('/api/export-daily-csv') +def export_daily_csv(): + """Export daily summary records as 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() + + csv_data = output.getvalue() + output.close() + + filename = f"daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + return Response( + csv_data, + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename={filename}', + 'Content-Type': 'text/csv; charset=utf-8' + } + ) + + +@app.route('/api/export-day-csv/') +def export_day_csv(date): + """Export batch details for a specific day as CSV.""" + conn = get_db() + cur = conn.cursor() + + # Batches for this day + 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() + + csv_data = output.getvalue() + output.close() + + filename = f"day_detail_{date}.csv" + return Response( + csv_data, + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename={filename}', + 'Content-Type': 'text/csv; charset=utf-8' + } + ) + + +# ------------------------------------------------------------------ # +# Run +# ------------------------------------------------------------------ # +if __name__ == '__main__': + # Suppress Flask's default request log spam + WSGIRequestHandler.protocol_version = "HTTP/1.1" + + port = int(os.getenv('DASHBOARD_PORT', 80)) + host = os.getenv('DASHBOARD_HOST', '0.0.0.0') + debug = os.getenv('FLASK_DEBUG', 'false').lower() == 'true' + + print(f"🚀 Dashboard running at http://{host}:{port}") + app.run(host=host, port=port, debug=debug) diff --git a/counter_service-test.py b/counter_service-test.py new file mode 100644 index 0000000..bede965 --- /dev/null +++ b/counter_service-test.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +""" +Frigate Object Batch Counter Service + +Listens to Frigate MQTT events, counts unique objects per batch, +and persists results to SQLite when a batch ends. +""" + +import os +import sys +import json +import sqlite3 +import threading +import time +import logging +import signal +from datetime import datetime, timedelta +from pathlib import Path + +import paho.mqtt.client as mqtt + + +class FrigateCounterService: + def __init__(self): + self.setup_logging() + self.load_config() + self.init_db() + self.state_lock = threading.Lock() + self.batch_timer = None + self.shutdown_event = threading.Event() + self.current_state = self.load_state() + + # Telenan Batch Label + self.ignore_batch_label = False + self.ignore_batch_label_timer = None + # ------------------------------------------------------------------ # + # Setup & Config + # ------------------------------------------------------------------ # + def setup_logging(self): + level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO) + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], + ) + self.logger = logging.getLogger(__name__) + + def load_config(self): + self.mqtt_host = os.getenv("FRIGATE_MQTT_HOST", "localhost") + self.mqtt_port = int(os.getenv("FRIGATE_MQTT_PORT", "1883")) + self.mqtt_user = os.getenv("FRIGATE_MQTT_USER") + self.mqtt_pass = os.getenv("FRIGATE_MQTT_PASS") + self.mqtt_topic = os.getenv("FRIGATE_MQTT_TOPIC", "frigate/events") + + self.camera_name = os.getenv("CAMERA_NAME") + if not self.camera_name: + raise ValueError("Environment variable CAMERA_NAME is required") + + self.object_label = os.getenv("OBJECT_LABEL", "ayam-potong") + self.batch_label = os.getenv("BATCH_LABEL", "telenan") # 20260514 - Adding Label telenan for new batch sign + self.ignore_batch_label_timeout = float(os.getenv("IGNORE_BATCH_LABEL_TIMEOUT_SECONDS", "30")) + self.batch_timeout = float(os.getenv("BATCH_TIMEOUT_SECONDS", "5")) + self.cutoff_time_str = os.getenv("DAILY_CUTOFF_TIME", "17:00") + + # Validate cutoff format HH:MM + datetime.strptime(self.cutoff_time_str, "%H:%M") + + self.db_path = os.getenv("DB_PATH", "/tmp/frigate_counter-test.db") + self.state_file = os.getenv("STATE_FILE", "/tmp/current_batch-test.json") + + self.min_duration_per_batch = int(os.getenv("MIN_DURATION_PER_BATCH", "60")) + # ------------------------------------------------------------------ # + # Database + # ------------------------------------------------------------------ # + def init_db(self): + self.db = sqlite3.connect(self.db_path, check_same_thread=False) + cur = self.db.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) + ) + """ + ) + + self.db.commit() + + # ------------------------------------------------------------------ # + # Counting-date logic (day ends at cutoff, e.g. 17:00) + # ------------------------------------------------------------------ # + def get_counting_date(self, dt=None): + """Return the business-day string that ends at cutoff_time.""" + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(self.cutoff_time_str, "%H:%M").time() + # e.g. cutoff 17:00 => 16:59 belongs to today, 17:00 belongs to tomorrow + if dt.time() < cutoff: + #if dt.time() >= cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + #return (dt.date() - timedelta(days=1)).isoformat() + + # ------------------------------------------------------------------ # + # State persistence (JSON) – survives restarts + # ------------------------------------------------------------------ # + def load_state(self): + if not Path(self.state_file).exists(): + return None + + try: + with open(self.state_file, "r", encoding="utf-8") as f: + state = json.load(f) + + current_date = self.get_counting_date() + if state.get("counting_date") != current_date: + self.logger.warning( + "State file belongs to previous counting day (%s). " + "Finalizing it before starting fresh.", + state.get("counting_date"), + ) + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + datetime.now().isoformat(), + ) + Path(self.state_file).unlink(missing_ok=True) + return None + + self.logger.info( + "Resumed batch #%s from %s with count=%s", + state["batch_number"], + state["start_time"], + state["count"], + ) + # Restart the inactivity timer + #self._reset_batch_timer() + return state + + except Exception as exc: + self.logger.error("Failed to load state file: %s", exc) + return None + + def save_state(self): + if self.current_state is None: + Path(self.state_file).unlink(missing_ok=True) + return + with open(self.state_file, "w", encoding="utf-8") as f: + json.dump(self.current_state, f, indent=2, ensure_ascii=False) + + # ------------------------------------------------------------------ # + # Batch lifecycle + # ------------------------------------------------------------------ # + def get_next_batch_number(self, counting_date): + cur = self.db.cursor() + cur.execute( + """ + SELECT COALESCE(MAX(batch_number), 0) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + return cur.fetchone()[0] + 1 + + def start_new_batch(self, counting_date): + batch_number = self.get_next_batch_number(counting_date) + now = datetime.now().isoformat() + self.current_state = { + "counting_date": counting_date, + "batch_number": batch_number, + "count": 0, + "start_time": now, + "last_detection_time": now, + "counted_event_ids": [], + } + self.save_state() + self.logger.info( + "Started batch #%s for %s (%s)", + batch_number, + counting_date, + self.object_label, + ) + + def process_detection(self, event_id, label): + """ + Called for every matching Frigate event (new/update/end). + Deduplicates by event_id and resets the 5-minute batch timer. + """ + should_reset_timer = False + + if label == self.object_label: + with self.state_lock: + counting_date = self.get_counting_date() + + # 1. No active batch -> start one + if self.current_state is None: + self.start_new_batch(counting_date) + should_reset_timer = True + + # 2. Cutoff crossed since batch started -> finalize old, start new + elif self.current_state["counting_date"] != counting_date: + self._end_batch_locked() + self.start_new_batch(counting_date) + should_reset_timer = True + + #self.current_state["count"] += 1 + + # 3. Deduplicate event ID + if event_id not in self.current_state["counted_event_ids"]: + self.current_state["count"] += 1 + self.current_state["counted_event_ids"].append(event_id) + self.logger.info( + "Counted %s (event %s) | batch #%s total: %s", + self.object_label, + event_id, + self.current_state["batch_number"], + self.current_state["count"], + ) + + # Always refresh last_detection_time so the batch stays alive + self.current_state["last_detection_time"] = datetime.now().isoformat() + self.save_state() + should_reset_timer = True + + elif label == self.batch_label: + self._ignore_batch_label() + self.end_batch() + + """ + if should_reset_timer: + self._reset_batch_timer() + """ + + def _ignore_batch_label(self): + if not self.ignore_batch_label_timer: + self.ignore_batch_label = True + self.ignore_batch_label_timer = threading.Timer(self.ignore_batch_label_timeout, self._on_ignore_batch_label_timeout) + self.ignore_batch_label_timer.daemon = True + self.ignore_batch_label_timer.start() + self.logger.info("Ignore Batch Label for %ss. self.ignore_batch_label = $s", self.ignore_batch_label_timeout, self.ignore_batch_label) + + def _reset_batch_timer(self): + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = threading.Timer(self.batch_timeout, self._on_batch_timeout) + self.batch_timer.daemon = True + self.batch_timer.start() + + def _on_ignore_batch_label_timeout(self): + self.ignore_batch_label = False + self.logger.info("Ignore Batch Label is done. self.ignore_batch_label = %s", self.ignore_batch_label) + self.end_batch() + + def _on_batch_timeout(self): + self.logger.info("Batch inactivity timeout (%ss) reached", self.batch_timeout) + self.end_batch() + + def end_batch(self): + with self.state_lock: + self._end_batch_locked() + + def _end_batch_locked(self): + if self.current_state is None: + return + + state = self.current_state + + """ Check Duration """ + start_time_obj = datetime.fromisoformat(state["start_time"]) + end_time_obj = datetime.now() + duration = end_time_obj - start_time_obj + duration_seconds = duration.total_seconds() + + if state["count"] == 0 or duration_seconds < self.min_duration_per_batch: + # Nothing to persist + self.current_state = None + self.save_state() + + """ + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + """ + + return + + end_time = datetime.now().isoformat() + + count_per_second = state["count"] / duration_seconds + + try: + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + end_time, + ) + self.logger.info( + "Batch #%s ended | count=%s | duration=%s | cps=%s", + state["batch_number"], + state["count"], + duration, + count_per_second + ) + except Exception as exc: + self.logger.error("Failed to persist batch: %s", exc) + # Leave state intact so we can retry on next timeout + return + + self.current_state = None + self.save_state() + """ + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + """ + + def _insert_batch(self, counting_date, batch_number, count, start_time, end_time): + """Atomic insert into batches + upsert daily summary.""" + cur = self.db.cursor() + + cur.execute( + """ + INSERT INTO batches + (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + counting_date, + batch_number, + self.camera_name, + self.object_label, + count, + start_time, + end_time, + ), + ) + + cur.execute( + """ + INSERT INTO daily_summaries + (counting_date, camera_name, object_label, total_count, total_batches) + VALUES (?, ?, ?, ?, 1) + ON CONFLICT(counting_date, camera_name, object_label) + DO UPDATE SET + total_count = total_count + excluded.total_count, + total_batches = total_batches + excluded.total_batches, + updated_at = CURRENT_TIMESTAMP + """, + (counting_date, self.camera_name, self.object_label, count), + ) + + self.db.commit() + + # Log running totals for the day + cur.execute( + """ + SELECT total_count, total_batches + FROM daily_summaries + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + row = cur.fetchone() + if row: + self.logger.info( + "Daily totals for %s: %s objects across %s batch(es)", + counting_date, + row[0], + row[1], + ) + + # ------------------------------------------------------------------ # + # Cutoff watcher (forces batch end at 17:00 etc.) + # ------------------------------------------------------------------ # + def cutoff_watcher(self): + """Runs every minute to force-close a batch when the business day rolls over.""" + while not self.shutdown_event.is_set(): + time.sleep(60) + with self.state_lock: + if self.current_state is None: + continue + if self.current_state["counting_date"] != self.get_counting_date(): + self.logger.info("Daily cutoff reached – finalizing batch") + self._end_batch_locked() + + # ------------------------------------------------------------------ # + # MQTT callbacks + # ------------------------------------------------------------------ # + def on_connect(self, client, userdata, flags, rc): + if rc == 0: + self.logger.info("MQTT connected to %s:%s", self.mqtt_host, self.mqtt_port) + client.subscribe(self.mqtt_topic) + self.logger.info("Subscribed to %s", self.mqtt_topic) + else: + self.logger.error("MQTT connection failed, code=%s", rc) + + def on_message(self, client, userdata, msg): + try: + payload = json.loads(msg.payload.decode("utf-8")) + after = payload.get("after", {}) + + """ 20260514 For Telenan """ + label = after.get("label") + + if after.get("camera") != self.camera_name: + return + + #if after.get("label") != self.object_label: + if label != self.object_label: + return + + if label != self.batch_label and self.ignore_batch_label: + return + + event_id = after.get("id") + if not event_id: + return + + self.process_detection(event_id, label) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/counter_service.py b/counter_service.py new file mode 100644 index 0000000..6ea13ab --- /dev/null +++ b/counter_service.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +""" +Frigate Object Batch Counter Service + +Listens to Frigate MQTT events, counts unique objects per batch, +and persists results to SQLite when a batch ends. +""" + +import os +import sys +import json +import sqlite3 +import threading +import time +import logging +import signal +from datetime import datetime, timedelta +from pathlib import Path + +import paho.mqtt.client as mqtt + + +class FrigateCounterService: + def __init__(self): + self.setup_logging() + self.load_config() + self.init_db() + self.state_lock = threading.Lock() + self.batch_timer = None + self.shutdown_event = threading.Event() + self.current_state = self.load_state() + + # Telenan Batch Label + self.ignore_batch_label = False + self.ignore_batch_label_timer = None + self.sleep_after_batch_label_detected = 10 + # ------------------------------------------------------------------ # + # Setup & Config + # ------------------------------------------------------------------ # + def setup_logging(self): + level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO) + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], + ) + self.logger = logging.getLogger(__name__) + + def load_config(self): + self.mqtt_host = os.getenv("FRIGATE_MQTT_HOST", "localhost") + self.mqtt_port = int(os.getenv("FRIGATE_MQTT_PORT", "1883")) + self.mqtt_user = os.getenv("FRIGATE_MQTT_USER") + self.mqtt_pass = os.getenv("FRIGATE_MQTT_PASS") + self.mqtt_topic = os.getenv("FRIGATE_MQTT_TOPIC", "frigate/events") + + self.camera_name = os.getenv("CAMERA_NAME") + if not self.camera_name: + raise ValueError("Environment variable CAMERA_NAME is required") + + self.object_label = os.getenv("OBJECT_LABEL", "ayam-potong") + self.batch_label = os.getenv("BATCH_LABEL", "telenan") # 20260514 - Adding Label telenan for new batch sign + self.ignore_batch_label_timeout = float(os.getenv("IGNORE_BATCH_LABEL_TIMEOUT_SECONDS", "30")) + self.batch_timeout = float(os.getenv("BATCH_TIMEOUT_SECONDS", "300")) + self.cutoff_time_str = os.getenv("DAILY_CUTOFF_TIME", "17:00") + + # Validate cutoff format HH:MM + datetime.strptime(self.cutoff_time_str, "%H:%M") + + self.db_path = os.getenv("DB_PATH", "frigate_counter.db") + self.state_file = os.getenv("STATE_FILE", "current_batch.json") + + self.min_duration_per_batch = int(os.getenv("MIN_DURATION_PER_BATCH", "60")) + self.min_object_per_batch = int(os.getenv("MIN_OBJECT_PER_BATCH", "60")) + # ------------------------------------------------------------------ # + # Database + # ------------------------------------------------------------------ # + def init_db(self): + self.db = sqlite3.connect(self.db_path, check_same_thread=False) + cur = self.db.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) + ) + """ + ) + + self.db.commit() + + # ------------------------------------------------------------------ # + # Counting-date logic (day ends at cutoff, e.g. 17:00) + # ------------------------------------------------------------------ # + def get_counting_date(self, dt=None): + """Return the business-day string that ends at cutoff_time.""" + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(self.cutoff_time_str, "%H:%M").time() + # e.g. cutoff 17:00 => 16:59 belongs to today, 17:00 belongs to tomorrow + if dt.time() < cutoff: + #if dt.time() >= cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + #return (dt.date() - timedelta(days=1)).isoformat() + + # ------------------------------------------------------------------ # + # State persistence (JSON) – survives restarts + # ------------------------------------------------------------------ # + def load_state(self): + if not Path(self.state_file).exists(): + return None + + try: + with open(self.state_file, "r", encoding="utf-8") as f: + state = json.load(f) + + current_date = self.get_counting_date() + if state.get("counting_date") != current_date: + self.logger.warning( + "State file belongs to previous counting day (%s). " + "Finalizing it before starting fresh.", + state.get("counting_date"), + ) + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + datetime.now().isoformat(), + ) + Path(self.state_file).unlink(missing_ok=True) + return None + + self.logger.info( + "Resumed batch #%s from %s with count=%s", + state["batch_number"], + state["start_time"], + state["count"], + ) + # Restart the inactivity timer + """ + Comment this to remove reset timer + """ + self._reset_batch_timer() + return state + + except Exception as exc: + self.logger.error("Failed to load state file: %s", exc) + return None + + def save_state(self): + if self.current_state is None: + Path(self.state_file).unlink(missing_ok=True) + return + with open(self.state_file, "w", encoding="utf-8") as f: + json.dump(self.current_state, f, indent=2, ensure_ascii=False) + + # ------------------------------------------------------------------ # + # Batch lifecycle + # ------------------------------------------------------------------ # + def get_next_batch_number(self, counting_date): + cur = self.db.cursor() + cur.execute( + """ + SELECT COALESCE(MAX(batch_number), 0) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + return cur.fetchone()[0] + 1 + + def start_new_batch(self, counting_date): + batch_number = self.get_next_batch_number(counting_date) + now = datetime.now().isoformat() + self.current_state = { + "counting_date": counting_date, + "batch_number": batch_number, + "count": 0, + "start_time": now, + "last_detection_time": now, + "counted_event_ids": [], + } + self.save_state() + self.logger.info( + "Started batch #%s for %s (%s)", + batch_number, + counting_date, + self.object_label, + ) + + def process_detection(self, event_id, label): + """ + Called for every matching Frigate event (new/update/end). + Deduplicates by event_id and resets the 5-minute batch timer. + """ + should_reset_timer = False + + if label == self.object_label: + with self.state_lock: + counting_date = self.get_counting_date() + + # 1. No active batch -> start one + if self.current_state is None: + self.start_new_batch(counting_date) + should_reset_timer = True + + # 2. Cutoff crossed since batch started -> finalize old, start new + elif self.current_state["counting_date"] != counting_date: + self._end_batch_locked() + self.start_new_batch(counting_date) + should_reset_timer = True + + # 3. Deduplicate event ID + if event_id not in self.current_state["counted_event_ids"]: + self.current_state["count"] += 1 + self.current_state["counted_event_ids"].append(event_id) + self.logger.info( + "Counted %s (event %s) | batch #%s total: %s", + self.object_label, + event_id, + self.current_state["batch_number"], + self.current_state["count"], + ) + + # Always refresh last_detection_time so the batch stays alive + self.current_state["last_detection_time"] = datetime.now().isoformat() + self.save_state() + should_reset_timer = True + + elif label == self.batch_label: + self._ignore_batch_label() + self.end_batch() + + self.logger.info("Batch Label detected. Sleep for %s seconds.", self.sleep_after_batch_label_detected) + time.sleep(self.sleep_after_batch_label_detected) + + should_reset_timer = True + + """ + Comment this to remove reset timer + """ + if should_reset_timer: + self._reset_batch_timer() + + def _ignore_batch_label(self): + if not self.ignore_batch_label_timer: + self.ignore_batch_label = True + self.ignore_batch_label_timer = threading.Timer(self.ignore_batch_label_timeout, self._on_ignore_batch_label_timeout) + self.ignore_batch_label_timer.daemon = True + self.ignore_batch_label_timer.start() + self.logger.info("Ignore Batch Label for %ss. self.ignore_batch_label = %s", self.ignore_batch_label_timeout, self.ignore_batch_label) + + def _reset_batch_timer(self): + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = threading.Timer(self.batch_timeout, self._on_batch_timeout) + self.batch_timer.daemon = True + self.batch_timer.start() + + def _on_ignore_batch_label_timeout(self): + self.ignore_batch_label_timer = None + self.ignore_batch_label = False + self.logger.info("Ignore Batch Label is done. self.ignore_batch_label = %s", self.ignore_batch_label) + #self.end_batch() + + def _on_batch_timeout(self): + self.logger.info("Batch inactivity timeout (%ss) reached", self.batch_timeout) + self.end_batch() + + def end_batch(self): + with self.state_lock: + self._end_batch_locked() + + def _end_batch_locked(self): + if self.current_state is None: + return + + state = self.current_state + + """ Check Duration """ + start_time_obj = datetime.fromisoformat(state["start_time"]) + end_time_obj = datetime.now() + duration = end_time_obj - start_time_obj + duration_seconds = duration.total_seconds() + + # Minimal conditions per batch + #if state["count"] == 0 or duration_seconds < self.min_duration_per_batch: + if state["count"] < self.min_object_per_batch or duration_seconds < self.min_duration_per_batch: + # Nothing to persist + self.current_state = None + self.save_state() + + """ + Related to _reset_batch_timer + """ + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + + return + + end_time = datetime.now().isoformat() + + count_per_second = state["count"] / duration_seconds + + try: + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + end_time, + ) + self.logger.info( + "Batch #%s ended | count=%s | duration=%s | cps=%s", + state["batch_number"], + state["count"], + duration, + count_per_second + ) + except Exception as exc: + self.logger.error("Failed to persist batch: %s", exc) + # Leave state intact so we can retry on next timeout + return + + self.current_state = None + self.save_state() + """ + Related to _reset_batch_timer + """ + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + + def _insert_batch(self, counting_date, batch_number, count, start_time, end_time): + """Atomic insert into batches + upsert daily summary.""" + cur = self.db.cursor() + + cur.execute( + """ + INSERT INTO batches + (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + counting_date, + batch_number, + self.camera_name, + self.object_label, + count, + start_time, + end_time, + ), + ) + + cur.execute( + """ + INSERT INTO daily_summaries + (counting_date, camera_name, object_label, total_count, total_batches) + VALUES (?, ?, ?, ?, 1) + ON CONFLICT(counting_date, camera_name, object_label) + DO UPDATE SET + total_count = total_count + excluded.total_count, + total_batches = total_batches + excluded.total_batches, + updated_at = CURRENT_TIMESTAMP + """, + (counting_date, self.camera_name, self.object_label, count), + ) + + self.db.commit() + + # Log running totals for the day + cur.execute( + """ + SELECT total_count, total_batches + FROM daily_summaries + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + row = cur.fetchone() + if row: + self.logger.info( + "Daily totals for %s: %s objects across %s batch(es)", + counting_date, + row[0], + row[1], + ) + + # ------------------------------------------------------------------ # + # Cutoff watcher (forces batch end at 17:00 etc.) + # ------------------------------------------------------------------ # + def cutoff_watcher(self): + """Runs every minute to force-close a batch when the business day rolls over.""" + while not self.shutdown_event.is_set(): + time.sleep(60) + with self.state_lock: + if self.current_state is None: + continue + if self.current_state["counting_date"] != self.get_counting_date(): + self.logger.info("Daily cutoff reached – finalizing batch") + self._end_batch_locked() + + # ------------------------------------------------------------------ # + # MQTT callbacks + # ------------------------------------------------------------------ # + def on_connect(self, client, userdata, flags, rc): + if rc == 0: + self.logger.info("MQTT connected to %s:%s", self.mqtt_host, self.mqtt_port) + client.subscribe(self.mqtt_topic) + self.logger.info("Subscribed to %s", self.mqtt_topic) + else: + self.logger.error("MQTT connection failed, code=%s", rc) + + def on_message(self, client, userdata, msg): + try: + payload = json.loads(msg.payload.decode("utf-8")) + after = payload.get("after", {}) + + """ 20260514 For Telenan """ + label = after.get("label") + camera = after.get("camera") + + if camera != self.camera_name: + return + + #if after.get("label") != self.object_label: + if label != self.object_label: + if label == self.batch_label and self.ignore_batch_label: + return + + #if label != self.batch_label and self.ignore_batch_label: + # return + + event_id = after.get("id") + if not event_id: + return + + self.process_detection(event_id, label) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/counter_service.py.20260514 b/counter_service.py.20260514 new file mode 100644 index 0000000..e03a336 --- /dev/null +++ b/counter_service.py.20260514 @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +""" +Frigate Object Batch Counter Service + +Listens to Frigate MQTT events, counts unique objects per batch, +and persists results to SQLite when a batch ends. +""" + +import os +import sys +import json +import sqlite3 +import threading +import time +import logging +import signal +from datetime import datetime, timedelta +from pathlib import Path + +import paho.mqtt.client as mqtt + + +class FrigateCounterService: + def __init__(self): + self.setup_logging() + self.load_config() + self.init_db() + self.state_lock = threading.Lock() + self.batch_timer = None + self.shutdown_event = threading.Event() + self.current_state = self.load_state() + + # ------------------------------------------------------------------ # + # Setup & Config + # ------------------------------------------------------------------ # + def setup_logging(self): + level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO) + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], + ) + self.logger = logging.getLogger(__name__) + + def load_config(self): + self.mqtt_host = os.getenv("FRIGATE_MQTT_HOST", "localhost") + self.mqtt_port = int(os.getenv("FRIGATE_MQTT_PORT", "1883")) + self.mqtt_user = os.getenv("FRIGATE_MQTT_USER") + self.mqtt_pass = os.getenv("FRIGATE_MQTT_PASS") + self.mqtt_topic = os.getenv("FRIGATE_MQTT_TOPIC", "frigate/events") + + self.camera_name = os.getenv("CAMERA_NAME") + if not self.camera_name: + raise ValueError("Environment variable CAMERA_NAME is required") + + self.object_label = os.getenv("OBJECT_LABEL", "ayam-potong") + self.batch_timeout = float(os.getenv("BATCH_TIMEOUT_SECONDS", "5")) + self.cutoff_time_str = os.getenv("DAILY_CUTOFF_TIME", "17:00") + + # Validate cutoff format HH:MM + datetime.strptime(self.cutoff_time_str, "%H:%M") + + self.db_path = os.getenv("DB_PATH", "frigate_counter.db") + self.state_file = os.getenv("STATE_FILE", "current_batch.json") + + self.min_duration_per_batch = int(os.getenv("MIN_DURATION_PER_BATCH", "60")) + # ------------------------------------------------------------------ # + # Database + # ------------------------------------------------------------------ # + def init_db(self): + self.db = sqlite3.connect(self.db_path, check_same_thread=False) + cur = self.db.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) + ) + """ + ) + + self.db.commit() + + # ------------------------------------------------------------------ # + # Counting-date logic (day ends at cutoff, e.g. 17:00) + # ------------------------------------------------------------------ # + def get_counting_date(self, dt=None): + """Return the business-day string that ends at cutoff_time.""" + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(self.cutoff_time_str, "%H:%M").time() + # e.g. cutoff 17:00 => 16:59 belongs to today, 17:00 belongs to tomorrow + if dt.time() < cutoff: + #if dt.time() >= cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + #return (dt.date() - timedelta(days=1)).isoformat() + + # ------------------------------------------------------------------ # + # State persistence (JSON) – survives restarts + # ------------------------------------------------------------------ # + def load_state(self): + if not Path(self.state_file).exists(): + return None + + try: + with open(self.state_file, "r", encoding="utf-8") as f: + state = json.load(f) + + current_date = self.get_counting_date() + if state.get("counting_date") != current_date: + self.logger.warning( + "State file belongs to previous counting day (%s). " + "Finalizing it before starting fresh.", + state.get("counting_date"), + ) + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + datetime.now().isoformat(), + ) + Path(self.state_file).unlink(missing_ok=True) + return None + + self.logger.info( + "Resumed batch #%s from %s with count=%s", + state["batch_number"], + state["start_time"], + state["count"], + ) + # Restart the inactivity timer + self._reset_batch_timer() + return state + + except Exception as exc: + self.logger.error("Failed to load state file: %s", exc) + return None + + def save_state(self): + if self.current_state is None: + Path(self.state_file).unlink(missing_ok=True) + return + with open(self.state_file, "w", encoding="utf-8") as f: + json.dump(self.current_state, f, indent=2, ensure_ascii=False) + + # ------------------------------------------------------------------ # + # Batch lifecycle + # ------------------------------------------------------------------ # + def get_next_batch_number(self, counting_date): + cur = self.db.cursor() + cur.execute( + """ + SELECT COALESCE(MAX(batch_number), 0) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + return cur.fetchone()[0] + 1 + + def start_new_batch(self, counting_date): + batch_number = self.get_next_batch_number(counting_date) + now = datetime.now().isoformat() + self.current_state = { + "counting_date": counting_date, + "batch_number": batch_number, + "count": 0, + "start_time": now, + "last_detection_time": now, + "counted_event_ids": [], + } + self.save_state() + self.logger.info( + "Started batch #%s for %s (%s)", + batch_number, + counting_date, + self.object_label, + ) + + def process_detection(self, event_id): + """ + Called for every matching Frigate event (new/update/end). + Deduplicates by event_id and resets the 5-minute batch timer. + """ + should_reset_timer = False + + with self.state_lock: + counting_date = self.get_counting_date() + + # 1. No active batch -> start one + if self.current_state is None: + self.start_new_batch(counting_date) + should_reset_timer = True + + # 2. Cutoff crossed since batch started -> finalize old, start new + elif self.current_state["counting_date"] != counting_date: + self._end_batch_locked() + self.start_new_batch(counting_date) + should_reset_timer = True + + #self.current_state["count"] += 1 + + # 3. Deduplicate event ID + if event_id not in self.current_state["counted_event_ids"]: + self.current_state["count"] += 1 + self.current_state["counted_event_ids"].append(event_id) + self.logger.info( + "Counted %s (event %s) | batch #%s total: %s", + self.object_label, + event_id, + self.current_state["batch_number"], + self.current_state["count"], + ) + + # Always refresh last_detection_time so the batch stays alive + self.current_state["last_detection_time"] = datetime.now().isoformat() + self.save_state() + should_reset_timer = True + + if should_reset_timer: + self._reset_batch_timer() + + def _reset_batch_timer(self): + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = threading.Timer(self.batch_timeout, self._on_batch_timeout) + self.batch_timer.daemon = True + self.batch_timer.start() + + def _on_batch_timeout(self): + self.logger.info("Batch inactivity timeout (%ss) reached", self.batch_timeout) + self.end_batch() + + def end_batch(self): + with self.state_lock: + self._end_batch_locked() + + def _end_batch_locked(self): + if self.current_state is None: + return + + state = self.current_state + + """ Check Duration """ + start_time_obj = datetime.fromisoformat(state["start_time"]) + end_time_obj = datetime.now() + duration = end_time_obj - start_time_obj + duration_seconds = duration.total_seconds() + + if state["count"] == 0 or duration_seconds < self.min_duration_per_batch: + # Nothing to persist + self.current_state = None + self.save_state() + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + return + + end_time = datetime.now().isoformat() + + count_per_second = state["count"] / duration_seconds + + try: + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + end_time, + ) + self.logger.info( + "Batch #%s ended | count=%s | duration=%s | cps=%s", + state["batch_number"], + state["count"], + duration, + count_per_second + ) + except Exception as exc: + self.logger.error("Failed to persist batch: %s", exc) + # Leave state intact so we can retry on next timeout + return + + self.current_state = None + self.save_state() + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + + def _insert_batch(self, counting_date, batch_number, count, start_time, end_time): + """Atomic insert into batches + upsert daily summary.""" + cur = self.db.cursor() + + cur.execute( + """ + INSERT INTO batches + (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + counting_date, + batch_number, + self.camera_name, + self.object_label, + count, + start_time, + end_time, + ), + ) + + cur.execute( + """ + INSERT INTO daily_summaries + (counting_date, camera_name, object_label, total_count, total_batches) + VALUES (?, ?, ?, ?, 1) + ON CONFLICT(counting_date, camera_name, object_label) + DO UPDATE SET + total_count = total_count + excluded.total_count, + total_batches = total_batches + excluded.total_batches, + updated_at = CURRENT_TIMESTAMP + """, + (counting_date, self.camera_name, self.object_label, count), + ) + + self.db.commit() + + # Log running totals for the day + cur.execute( + """ + SELECT total_count, total_batches + FROM daily_summaries + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + row = cur.fetchone() + if row: + self.logger.info( + "Daily totals for %s: %s objects across %s batch(es)", + counting_date, + row[0], + row[1], + ) + + # ------------------------------------------------------------------ # + # Cutoff watcher (forces batch end at 17:00 etc.) + # ------------------------------------------------------------------ # + def cutoff_watcher(self): + """Runs every minute to force-close a batch when the business day rolls over.""" + while not self.shutdown_event.is_set(): + time.sleep(60) + with self.state_lock: + if self.current_state is None: + continue + if self.current_state["counting_date"] != self.get_counting_date(): + self.logger.info("Daily cutoff reached – finalizing batch") + self._end_batch_locked() + + # ------------------------------------------------------------------ # + # MQTT callbacks + # ------------------------------------------------------------------ # + def on_connect(self, client, userdata, flags, rc): + if rc == 0: + self.logger.info("MQTT connected to %s:%s", self.mqtt_host, self.mqtt_port) + client.subscribe(self.mqtt_topic) + self.logger.info("Subscribed to %s", self.mqtt_topic) + else: + self.logger.error("MQTT connection failed, code=%s", rc) + + def on_message(self, client, userdata, msg): + try: + payload = json.loads(msg.payload.decode("utf-8")) + after = payload.get("after", {}) + + if after.get("camera") != self.camera_name: + return + if after.get("label") != self.object_label: + return + + event_id = after.get("id") + if not event_id: + return + + self.process_detection(event_id) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/counter_service.py.20260518 b/counter_service.py.20260518 new file mode 100644 index 0000000..ebb20c4 --- /dev/null +++ b/counter_service.py.20260518 @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +""" +Frigate Object Batch Counter Service + +Listens to Frigate MQTT events, counts unique objects per batch, +and persists results to SQLite when a batch ends. +""" + +import os +import sys +import json +import sqlite3 +import threading +import time +import logging +import signal +from datetime import datetime, timedelta +from pathlib import Path + +import paho.mqtt.client as mqtt + + +class FrigateCounterService: + def __init__(self): + self.setup_logging() + self.load_config() + self.init_db() + self.state_lock = threading.Lock() + self.batch_timer = None + self.shutdown_event = threading.Event() + self.current_state = self.load_state() + + # Telenan Batch Label + self.ignore_batch_label = False + self.ignore_batch_label_timer = None + # ------------------------------------------------------------------ # + # Setup & Config + # ------------------------------------------------------------------ # + def setup_logging(self): + level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO) + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], + ) + self.logger = logging.getLogger(__name__) + + def load_config(self): + self.mqtt_host = os.getenv("FRIGATE_MQTT_HOST", "localhost") + self.mqtt_port = int(os.getenv("FRIGATE_MQTT_PORT", "1883")) + self.mqtt_user = os.getenv("FRIGATE_MQTT_USER") + self.mqtt_pass = os.getenv("FRIGATE_MQTT_PASS") + self.mqtt_topic = os.getenv("FRIGATE_MQTT_TOPIC", "frigate/events") + + self.camera_name = os.getenv("CAMERA_NAME") + if not self.camera_name: + raise ValueError("Environment variable CAMERA_NAME is required") + + self.object_label = os.getenv("OBJECT_LABEL", "ayam-potong") + self.batch_label = os.getenv("BATCH_LABEL", "telenan") # 20260514 - Adding Label telenan for new batch sign + self.ignore_batch_label_timeout = float(os.getenv("IGNORE_BATCH_LABEL_TIMEOUT_SECONDS", "30")) + self.batch_timeout = float(os.getenv("BATCH_TIMEOUT_SECONDS", "300")) + self.cutoff_time_str = os.getenv("DAILY_CUTOFF_TIME", "17:00") + + # Validate cutoff format HH:MM + datetime.strptime(self.cutoff_time_str, "%H:%M") + + self.db_path = os.getenv("DB_PATH", "frigate_counter.db") + self.state_file = os.getenv("STATE_FILE", "current_batch.json") + + self.min_duration_per_batch = int(os.getenv("MIN_DURATION_PER_BATCH", "60")) + self.min_object_per_batch = int(os.getenv("MIN_OBJECT_PER_BATCH", "60")) + # ------------------------------------------------------------------ # + # Database + # ------------------------------------------------------------------ # + def init_db(self): + self.db = sqlite3.connect(self.db_path, check_same_thread=False) + cur = self.db.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) + ) + """ + ) + + self.db.commit() + + # ------------------------------------------------------------------ # + # Counting-date logic (day ends at cutoff, e.g. 17:00) + # ------------------------------------------------------------------ # + def get_counting_date(self, dt=None): + """Return the business-day string that ends at cutoff_time.""" + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(self.cutoff_time_str, "%H:%M").time() + # e.g. cutoff 17:00 => 16:59 belongs to today, 17:00 belongs to tomorrow + if dt.time() < cutoff: + #if dt.time() >= cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + #return (dt.date() - timedelta(days=1)).isoformat() + + # ------------------------------------------------------------------ # + # State persistence (JSON) – survives restarts + # ------------------------------------------------------------------ # + def load_state(self): + if not Path(self.state_file).exists(): + return None + + try: + with open(self.state_file, "r", encoding="utf-8") as f: + state = json.load(f) + + current_date = self.get_counting_date() + if state.get("counting_date") != current_date: + self.logger.warning( + "State file belongs to previous counting day (%s). " + "Finalizing it before starting fresh.", + state.get("counting_date"), + ) + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + datetime.now().isoformat(), + ) + Path(self.state_file).unlink(missing_ok=True) + return None + + self.logger.info( + "Resumed batch #%s from %s with count=%s", + state["batch_number"], + state["start_time"], + state["count"], + ) + # Restart the inactivity timer + """ + Comment this to remove reset timer + """ + self._reset_batch_timer() + return state + + except Exception as exc: + self.logger.error("Failed to load state file: %s", exc) + return None + + def save_state(self): + if self.current_state is None: + Path(self.state_file).unlink(missing_ok=True) + return + with open(self.state_file, "w", encoding="utf-8") as f: + json.dump(self.current_state, f, indent=2, ensure_ascii=False) + + # ------------------------------------------------------------------ # + # Batch lifecycle + # ------------------------------------------------------------------ # + def get_next_batch_number(self, counting_date): + cur = self.db.cursor() + cur.execute( + """ + SELECT COALESCE(MAX(batch_number), 0) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + return cur.fetchone()[0] + 1 + + def start_new_batch(self, counting_date): + batch_number = self.get_next_batch_number(counting_date) + now = datetime.now().isoformat() + self.current_state = { + "counting_date": counting_date, + "batch_number": batch_number, + "count": 0, + "start_time": now, + "last_detection_time": now, + "counted_event_ids": [], + } + self.save_state() + self.logger.info( + "Started batch #%s for %s (%s)", + batch_number, + counting_date, + self.object_label, + ) + + def process_detection(self, event_id, label): + """ + Called for every matching Frigate event (new/update/end). + Deduplicates by event_id and resets the 5-minute batch timer. + """ + should_reset_timer = False + + if label == self.object_label: + with self.state_lock: + counting_date = self.get_counting_date() + + # 1. No active batch -> start one + if self.current_state is None: + self.start_new_batch(counting_date) + should_reset_timer = True + + # 2. Cutoff crossed since batch started -> finalize old, start new + elif self.current_state["counting_date"] != counting_date: + self._end_batch_locked() + self.start_new_batch(counting_date) + should_reset_timer = True + + # 3. Deduplicate event ID + if event_id not in self.current_state["counted_event_ids"]: + self.current_state["count"] += 1 + self.current_state["counted_event_ids"].append(event_id) + self.logger.info( + "Counted %s (event %s) | batch #%s total: %s", + self.object_label, + event_id, + self.current_state["batch_number"], + self.current_state["count"], + ) + + # Always refresh last_detection_time so the batch stays alive + self.current_state["last_detection_time"] = datetime.now().isoformat() + self.save_state() + should_reset_timer = True + + elif label == self.batch_label: + self._ignore_batch_label() + self.end_batch() + time.sleep(5) + should_reset_timer = True + + """ + Comment this to remove reset timer + """ + if should_reset_timer: + self._reset_batch_timer() + + def _ignore_batch_label(self): + if not self.ignore_batch_label_timer: + self.ignore_batch_label = True + self.ignore_batch_label_timer = threading.Timer(self.ignore_batch_label_timeout, self._on_ignore_batch_label_timeout) + self.ignore_batch_label_timer.daemon = True + self.ignore_batch_label_timer.start() + self.logger.info("Ignore Batch Label for %ss. self.ignore_batch_label = %s", self.ignore_batch_label_timeout, self.ignore_batch_label) + + def _reset_batch_timer(self): + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = threading.Timer(self.batch_timeout, self._on_batch_timeout) + self.batch_timer.daemon = True + self.batch_timer.start() + + def _on_ignore_batch_label_timeout(self): + self.ignore_batch_label_timer = None + self.ignore_batch_label = False + self.logger.info("Ignore Batch Label is done. self.ignore_batch_label = %s", self.ignore_batch_label) + #self.end_batch() + + def _on_batch_timeout(self): + self.logger.info("Batch inactivity timeout (%ss) reached", self.batch_timeout) + self.end_batch() + + def end_batch(self): + with self.state_lock: + self._end_batch_locked() + + def _end_batch_locked(self): + if self.current_state is None: + return + + state = self.current_state + + """ Check Duration """ + start_time_obj = datetime.fromisoformat(state["start_time"]) + end_time_obj = datetime.now() + duration = end_time_obj - start_time_obj + duration_seconds = duration.total_seconds() + + if state["count"] == 0 or duration_seconds < self.min_duration_per_batch: + #if state["count"] < self.min_object_per_batch or duration_seconds < self.min_duration_per_batch: + # Nothing to persist + self.current_state = None + self.save_state() + + """ + Related to _reset_batch_timer + """ + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + + return + + end_time = datetime.now().isoformat() + + count_per_second = state["count"] / duration_seconds + + try: + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + end_time, + ) + self.logger.info( + "Batch #%s ended | count=%s | duration=%s | cps=%s", + state["batch_number"], + state["count"], + duration, + count_per_second + ) + except Exception as exc: + self.logger.error("Failed to persist batch: %s", exc) + # Leave state intact so we can retry on next timeout + return + + self.current_state = None + self.save_state() + """ + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + """ + + def _insert_batch(self, counting_date, batch_number, count, start_time, end_time): + """Atomic insert into batches + upsert daily summary.""" + cur = self.db.cursor() + + cur.execute( + """ + INSERT INTO batches + (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + counting_date, + batch_number, + self.camera_name, + self.object_label, + count, + start_time, + end_time, + ), + ) + + cur.execute( + """ + INSERT INTO daily_summaries + (counting_date, camera_name, object_label, total_count, total_batches) + VALUES (?, ?, ?, ?, 1) + ON CONFLICT(counting_date, camera_name, object_label) + DO UPDATE SET + total_count = total_count + excluded.total_count, + total_batches = total_batches + excluded.total_batches, + updated_at = CURRENT_TIMESTAMP + """, + (counting_date, self.camera_name, self.object_label, count), + ) + + self.db.commit() + + # Log running totals for the day + cur.execute( + """ + SELECT total_count, total_batches + FROM daily_summaries + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + row = cur.fetchone() + if row: + self.logger.info( + "Daily totals for %s: %s objects across %s batch(es)", + counting_date, + row[0], + row[1], + ) + + # ------------------------------------------------------------------ # + # Cutoff watcher (forces batch end at 17:00 etc.) + # ------------------------------------------------------------------ # + def cutoff_watcher(self): + """Runs every minute to force-close a batch when the business day rolls over.""" + while not self.shutdown_event.is_set(): + time.sleep(60) + with self.state_lock: + if self.current_state is None: + continue + if self.current_state["counting_date"] != self.get_counting_date(): + self.logger.info("Daily cutoff reached – finalizing batch") + self._end_batch_locked() + + # ------------------------------------------------------------------ # + # MQTT callbacks + # ------------------------------------------------------------------ # + def on_connect(self, client, userdata, flags, rc): + if rc == 0: + self.logger.info("MQTT connected to %s:%s", self.mqtt_host, self.mqtt_port) + client.subscribe(self.mqtt_topic) + self.logger.info("Subscribed to %s", self.mqtt_topic) + else: + self.logger.error("MQTT connection failed, code=%s", rc) + + def on_message(self, client, userdata, msg): + try: + payload = json.loads(msg.payload.decode("utf-8")) + after = payload.get("after", {}) + + """ 20260514 For Telenan """ + label = after.get("label") + camera = after.get("camera") + + if camera != self.camera_name: + return + + #if after.get("label") != self.object_label: + if label != self.object_label: + if label == self.batch_label and self.ignore_batch_label: + return + + #if label != self.batch_label and self.ignore_batch_label: + # return + + event_id = after.get("id") + if not event_id: + return + + self.process_detection(event_id, label) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/counter_service.py.no_reset_timer b/counter_service.py.no_reset_timer new file mode 100644 index 0000000..26a1e78 --- /dev/null +++ b/counter_service.py.no_reset_timer @@ -0,0 +1,505 @@ +#!/usr/bin/env python3 +""" +Frigate Object Batch Counter Service + +Listens to Frigate MQTT events, counts unique objects per batch, +and persists results to SQLite when a batch ends. +""" + +import os +import sys +import json +import sqlite3 +import threading +import time +import logging +import signal +from datetime import datetime, timedelta +from pathlib import Path + +import paho.mqtt.client as mqtt + + +class FrigateCounterService: + def __init__(self): + self.setup_logging() + self.load_config() + self.init_db() + self.state_lock = threading.Lock() + self.batch_timer = None + self.shutdown_event = threading.Event() + self.current_state = self.load_state() + + # Telenan Batch Label + self.ignore_batch_label = False + self.ignore_batch_label_timer = None + # ------------------------------------------------------------------ # + # Setup & Config + # ------------------------------------------------------------------ # + def setup_logging(self): + level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO) + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], + ) + self.logger = logging.getLogger(__name__) + + def load_config(self): + self.mqtt_host = os.getenv("FRIGATE_MQTT_HOST", "localhost") + self.mqtt_port = int(os.getenv("FRIGATE_MQTT_PORT", "1883")) + self.mqtt_user = os.getenv("FRIGATE_MQTT_USER") + self.mqtt_pass = os.getenv("FRIGATE_MQTT_PASS") + self.mqtt_topic = os.getenv("FRIGATE_MQTT_TOPIC", "frigate/events") + + self.camera_name = os.getenv("CAMERA_NAME") + if not self.camera_name: + raise ValueError("Environment variable CAMERA_NAME is required") + + self.object_label = os.getenv("OBJECT_LABEL", "ayam-potong") + self.batch_label = os.getenv("BATCH_LABEL", "telenan") # 20260514 - Adding Label telenan for new batch sign + self.ignore_batch_label_timeout = float(os.getenv("IGNORE_BATCH_LABEL_TIMEOUT_SECONDS", "30")) + self.batch_timeout = float(os.getenv("BATCH_TIMEOUT_SECONDS", "300")) + self.cutoff_time_str = os.getenv("DAILY_CUTOFF_TIME", "17:00") + + # Validate cutoff format HH:MM + datetime.strptime(self.cutoff_time_str, "%H:%M") + + self.db_path = os.getenv("DB_PATH", "frigate_counter.db") + self.state_file = os.getenv("STATE_FILE", "current_batch.json") + + self.min_duration_per_batch = int(os.getenv("MIN_DURATION_PER_BATCH", "60")) + # ------------------------------------------------------------------ # + # Database + # ------------------------------------------------------------------ # + def init_db(self): + self.db = sqlite3.connect(self.db_path, check_same_thread=False) + cur = self.db.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) + ) + """ + ) + + self.db.commit() + + # ------------------------------------------------------------------ # + # Counting-date logic (day ends at cutoff, e.g. 17:00) + # ------------------------------------------------------------------ # + def get_counting_date(self, dt=None): + """Return the business-day string that ends at cutoff_time.""" + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(self.cutoff_time_str, "%H:%M").time() + # e.g. cutoff 17:00 => 16:59 belongs to today, 17:00 belongs to tomorrow + if dt.time() < cutoff: + #if dt.time() >= cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + #return (dt.date() - timedelta(days=1)).isoformat() + + # ------------------------------------------------------------------ # + # State persistence (JSON) – survives restarts + # ------------------------------------------------------------------ # + def load_state(self): + if not Path(self.state_file).exists(): + return None + + try: + with open(self.state_file, "r", encoding="utf-8") as f: + state = json.load(f) + + current_date = self.get_counting_date() + if state.get("counting_date") != current_date: + self.logger.warning( + "State file belongs to previous counting day (%s). " + "Finalizing it before starting fresh.", + state.get("counting_date"), + ) + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + datetime.now().isoformat(), + ) + Path(self.state_file).unlink(missing_ok=True) + return None + + self.logger.info( + "Resumed batch #%s from %s with count=%s", + state["batch_number"], + state["start_time"], + state["count"], + ) + # Restart the inactivity timer + #self._reset_batch_timer() + return state + + except Exception as exc: + self.logger.error("Failed to load state file: %s", exc) + return None + + def save_state(self): + if self.current_state is None: + Path(self.state_file).unlink(missing_ok=True) + return + with open(self.state_file, "w", encoding="utf-8") as f: + json.dump(self.current_state, f, indent=2, ensure_ascii=False) + + # ------------------------------------------------------------------ # + # Batch lifecycle + # ------------------------------------------------------------------ # + def get_next_batch_number(self, counting_date): + cur = self.db.cursor() + cur.execute( + """ + SELECT COALESCE(MAX(batch_number), 0) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + return cur.fetchone()[0] + 1 + + def start_new_batch(self, counting_date): + batch_number = self.get_next_batch_number(counting_date) + now = datetime.now().isoformat() + self.current_state = { + "counting_date": counting_date, + "batch_number": batch_number, + "count": 0, + "start_time": now, + "last_detection_time": now, + "counted_event_ids": [], + } + self.save_state() + self.logger.info( + "Started batch #%s for %s (%s)", + batch_number, + counting_date, + self.object_label, + ) + + def process_detection(self, event_id, label): + """ + Called for every matching Frigate event (new/update/end). + Deduplicates by event_id and resets the 5-minute batch timer. + """ + should_reset_timer = False + + if label == self.object_label: + with self.state_lock: + counting_date = self.get_counting_date() + + # 1. No active batch -> start one + if self.current_state is None: + self.start_new_batch(counting_date) + should_reset_timer = True + + # 2. Cutoff crossed since batch started -> finalize old, start new + elif self.current_state["counting_date"] != counting_date: + self._end_batch_locked() + self.start_new_batch(counting_date) + should_reset_timer = True + + #self.current_state["count"] += 1 + + # 3. Deduplicate event ID + if event_id not in self.current_state["counted_event_ids"]: + self.current_state["count"] += 1 + self.current_state["counted_event_ids"].append(event_id) + self.logger.info( + "Counted %s (event %s) | batch #%s total: %s", + self.object_label, + event_id, + self.current_state["batch_number"], + self.current_state["count"], + ) + + # Always refresh last_detection_time so the batch stays alive + self.current_state["last_detection_time"] = datetime.now().isoformat() + self.save_state() + should_reset_timer = True + + elif label == self.batch_label: + self._ignore_batch_label() + self.end_batch() + + """ + if should_reset_timer: + self._reset_batch_timer() + """ + + def _ignore_batch_label(self): + if not self.ignore_batch_label_timer: + self.ignore_batch_label = True + self.ignore_batch_label_timer = threading.Timer(self.ignore_batch_label_timeout, self._on_ignore_batch_label_timeout) + self.ignore_batch_label_timer.daemon = True + self.ignore_batch_label_timer.start() + self.logger.info("Ignore Batch Label for %ss. self.ignore_batch_label = %s", self.ignore_batch_label_timeout, self.ignore_batch_label) + + def _reset_batch_timer(self): + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = threading.Timer(self.batch_timeout, self._on_batch_timeout) + self.batch_timer.daemon = True + self.batch_timer.start() + + def _on_ignore_batch_label_timeout(self): + self.ignore_batch_label = False + self.logger.info("Ignore Batch Label is done. self.ignore_batch_label = %s", self.ignore_batch_label) + self.end_batch() + + def _on_batch_timeout(self): + self.logger.info("Batch inactivity timeout (%ss) reached", self.batch_timeout) + self.end_batch() + + def end_batch(self): + with self.state_lock: + self._end_batch_locked() + + def _end_batch_locked(self): + if self.current_state is None: + return + + state = self.current_state + + """ Check Duration """ + start_time_obj = datetime.fromisoformat(state["start_time"]) + end_time_obj = datetime.now() + duration = end_time_obj - start_time_obj + duration_seconds = duration.total_seconds() + + if state["count"] == 0 or duration_seconds < self.min_duration_per_batch: + # Nothing to persist + self.current_state = None + self.save_state() + + """ + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + """ + + return + + end_time = datetime.now().isoformat() + + count_per_second = state["count"] / duration_seconds + + try: + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + end_time, + ) + self.logger.info( + "Batch #%s ended | count=%s | duration=%s | cps=%s", + state["batch_number"], + state["count"], + duration, + count_per_second + ) + except Exception as exc: + self.logger.error("Failed to persist batch: %s", exc) + # Leave state intact so we can retry on next timeout + return + + self.current_state = None + self.save_state() + """ + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + """ + + def _insert_batch(self, counting_date, batch_number, count, start_time, end_time): + """Atomic insert into batches + upsert daily summary.""" + cur = self.db.cursor() + + cur.execute( + """ + INSERT INTO batches + (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + counting_date, + batch_number, + self.camera_name, + self.object_label, + count, + start_time, + end_time, + ), + ) + + cur.execute( + """ + INSERT INTO daily_summaries + (counting_date, camera_name, object_label, total_count, total_batches) + VALUES (?, ?, ?, ?, 1) + ON CONFLICT(counting_date, camera_name, object_label) + DO UPDATE SET + total_count = total_count + excluded.total_count, + total_batches = total_batches + excluded.total_batches, + updated_at = CURRENT_TIMESTAMP + """, + (counting_date, self.camera_name, self.object_label, count), + ) + + self.db.commit() + + # Log running totals for the day + cur.execute( + """ + SELECT total_count, total_batches + FROM daily_summaries + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + row = cur.fetchone() + if row: + self.logger.info( + "Daily totals for %s: %s objects across %s batch(es)", + counting_date, + row[0], + row[1], + ) + + # ------------------------------------------------------------------ # + # Cutoff watcher (forces batch end at 17:00 etc.) + # ------------------------------------------------------------------ # + def cutoff_watcher(self): + """Runs every minute to force-close a batch when the business day rolls over.""" + while not self.shutdown_event.is_set(): + time.sleep(60) + with self.state_lock: + if self.current_state is None: + continue + if self.current_state["counting_date"] != self.get_counting_date(): + self.logger.info("Daily cutoff reached – finalizing batch") + self._end_batch_locked() + + # ------------------------------------------------------------------ # + # MQTT callbacks + # ------------------------------------------------------------------ # + def on_connect(self, client, userdata, flags, rc): + if rc == 0: + self.logger.info("MQTT connected to %s:%s", self.mqtt_host, self.mqtt_port) + client.subscribe(self.mqtt_topic) + self.logger.info("Subscribed to %s", self.mqtt_topic) + else: + self.logger.error("MQTT connection failed, code=%s", rc) + + def on_message(self, client, userdata, msg): + try: + payload = json.loads(msg.payload.decode("utf-8")) + after = payload.get("after", {}) + + """ 20260514 For Telenan """ + label = after.get("label") + camera = after.get("camera") + + if camera != self.camera_name: + return + + #if after.get("label") != self.object_label: + if label != self.object_label: + if label == self.batch_label and self.ignore_batch_label: + return + + #if label != self.batch_label and self.ignore_batch_label: + # return + + event_id = after.get("id") + if not event_id: + return + + self.process_detection(event_id, label) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/counter_service.py.workarund b/counter_service.py.workarund new file mode 100644 index 0000000..2d97e9b --- /dev/null +++ b/counter_service.py.workarund @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +""" +Frigate Object Batch Counter Service + +Listens to Frigate MQTT events, counts unique objects per batch, +and persists results to SQLite when a batch ends. +""" + +import os +import sys +import json +import sqlite3 +import threading +import time +import logging +import signal +from datetime import datetime, timedelta +from pathlib import Path + +import paho.mqtt.client as mqtt + + +class FrigateCounterService: + def __init__(self): + self.setup_logging() + self.load_config() + self.init_db() + self.state_lock = threading.Lock() + self.batch_timer = None + self.shutdown_event = threading.Event() + self.current_state = self.load_state() + + # Telenan Batch Label + self.ignore_batch_label = False + self.ignore_batch_label_timer = None + # ------------------------------------------------------------------ # + # Setup & Config + # ------------------------------------------------------------------ # + def setup_logging(self): + level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO) + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], + ) + self.logger = logging.getLogger(__name__) + + def load_config(self): + self.mqtt_host = os.getenv("FRIGATE_MQTT_HOST", "localhost") + self.mqtt_port = int(os.getenv("FRIGATE_MQTT_PORT", "1883")) + self.mqtt_user = os.getenv("FRIGATE_MQTT_USER") + self.mqtt_pass = os.getenv("FRIGATE_MQTT_PASS") + self.mqtt_topic = os.getenv("FRIGATE_MQTT_TOPIC", "frigate/events") + + self.camera_name = os.getenv("CAMERA_NAME") + if not self.camera_name: + raise ValueError("Environment variable CAMERA_NAME is required") + + self.object_label = os.getenv("OBJECT_LABEL", "ayam-potong") + self.batch_label = os.getenv("BATCH_LABEL", "telenan") # 20260514 - Adding Label telenan for new batch sign + self.ignore_batch_label_timeout = float(os.getenv("IGNORE_BATCH_LABEL_TIMEOUT_SECONDS", "30")) + self.batch_timeout = float(os.getenv("BATCH_TIMEOUT_SECONDS", "300")) + self.cutoff_time_str = os.getenv("DAILY_CUTOFF_TIME", "17:00") + + # Validate cutoff format HH:MM + datetime.strptime(self.cutoff_time_str, "%H:%M") + + self.db_path = os.getenv("DB_PATH", "frigate_counter.db") + self.state_file = os.getenv("STATE_FILE", "current_batch.json") + + self.min_duration_per_batch = int(os.getenv("MIN_DURATION_PER_BATCH", "60")) + # ------------------------------------------------------------------ # + # Database + # ------------------------------------------------------------------ # + def init_db(self): + self.db = sqlite3.connect(self.db_path, check_same_thread=False) + cur = self.db.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) + ) + """ + ) + + self.db.commit() + + # ------------------------------------------------------------------ # + # Counting-date logic (day ends at cutoff, e.g. 17:00) + # ------------------------------------------------------------------ # + def get_counting_date(self, dt=None): + """Return the business-day string that ends at cutoff_time.""" + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(self.cutoff_time_str, "%H:%M").time() + # e.g. cutoff 17:00 => 16:59 belongs to today, 17:00 belongs to tomorrow + if dt.time() < cutoff: + #if dt.time() >= cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + #return (dt.date() - timedelta(days=1)).isoformat() + + # ------------------------------------------------------------------ # + # State persistence (JSON) – survives restarts + # ------------------------------------------------------------------ # + def load_state(self): + if not Path(self.state_file).exists(): + return None + + try: + with open(self.state_file, "r", encoding="utf-8") as f: + state = json.load(f) + + current_date = self.get_counting_date() + if state.get("counting_date") != current_date: + self.logger.warning( + "State file belongs to previous counting day (%s). " + "Finalizing it before starting fresh.", + state.get("counting_date"), + ) + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + datetime.now().isoformat(), + ) + Path(self.state_file).unlink(missing_ok=True) + return None + + self.logger.info( + "Resumed batch #%s from %s with count=%s", + state["batch_number"], + state["start_time"], + state["count"], + ) + # Restart the inactivity timer + """ + Comment this to remove reset timer + """ + self._reset_batch_timer() + return state + + except Exception as exc: + self.logger.error("Failed to load state file: %s", exc) + return None + + def save_state(self): + if self.current_state is None: + Path(self.state_file).unlink(missing_ok=True) + return + with open(self.state_file, "w", encoding="utf-8") as f: + json.dump(self.current_state, f, indent=2, ensure_ascii=False) + + # ------------------------------------------------------------------ # + # Batch lifecycle + # ------------------------------------------------------------------ # + def get_next_batch_number(self, counting_date): + cur = self.db.cursor() + cur.execute( + """ + SELECT COALESCE(MAX(batch_number), 0) + FROM batches + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + return cur.fetchone()[0] + 1 + + def start_new_batch(self, counting_date): + batch_number = self.get_next_batch_number(counting_date) + now = datetime.now().isoformat() + self.current_state = { + "counting_date": counting_date, + "batch_number": batch_number, + "count": 0, + "start_time": now, + "last_detection_time": now, + "counted_event_ids": [], + } + self.save_state() + self.logger.info( + "Started batch #%s for %s (%s)", + batch_number, + counting_date, + self.object_label, + ) + + def process_detection(self, event_id, label): + """ + Called for every matching Frigate event (new/update/end). + Deduplicates by event_id and resets the 5-minute batch timer. + """ + should_reset_timer = False + + if label == self.object_label: + with self.state_lock: + counting_date = self.get_counting_date() + + # 1. No active batch -> start one + if self.current_state is None: + self.start_new_batch(counting_date) + should_reset_timer = True + + # 2. Cutoff crossed since batch started -> finalize old, start new + elif self.current_state["counting_date"] != counting_date: + self._end_batch_locked() + self.start_new_batch(counting_date) + should_reset_timer = True + + # 3. Deduplicate event ID + if event_id not in self.current_state["counted_event_ids"]: + self.current_state["count"] += 1 + self.current_state["counted_event_ids"].append(event_id) + self.logger.info( + "Counted %s (event %s) | batch #%s total: %s", + self.object_label, + event_id, + self.current_state["batch_number"], + self.current_state["count"], + ) + + # Always refresh last_detection_time so the batch stays alive + self.current_state["last_detection_time"] = datetime.now().isoformat() + self.save_state() + should_reset_timer = True + + elif label == self.batch_label: + self._ignore_batch_label() + self.end_batch() + + """ + Comment this to remove reset timer + """ + if should_reset_timer: + self._reset_batch_timer() + + def _ignore_batch_label(self): + if not self.ignore_batch_label_timer: + self.ignore_batch_label = True + self.ignore_batch_label_timer = threading.Timer(self.ignore_batch_label_timeout, self._on_ignore_batch_label_timeout) + self.ignore_batch_label_timer.daemon = True + self.ignore_batch_label_timer.start() + self.logger.info("Ignore Batch Label for %ss. self.ignore_batch_label = %s", self.ignore_batch_label_timeout, self.ignore_batch_label) + + def _reset_batch_timer(self): + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = threading.Timer(self.batch_timeout, self._on_batch_timeout) + self.batch_timer.daemon = True + self.batch_timer.start() + + def _on_ignore_batch_label_timeout(self): + self.ignore_batch_label = False + self.logger.info("Ignore Batch Label is done. self.ignore_batch_label = %s", self.ignore_batch_label) + self.end_batch() + + def _on_batch_timeout(self): + self.logger.info("Batch inactivity timeout (%ss) reached", self.batch_timeout) + self.end_batch() + + def end_batch(self): + with self.state_lock: + self._end_batch_locked() + + def _end_batch_locked(self): + if self.current_state is None: + return + + state = self.current_state + + """ Check Duration """ + start_time_obj = datetime.fromisoformat(state["start_time"]) + end_time_obj = datetime.now() + duration = end_time_obj - start_time_obj + duration_seconds = duration.total_seconds() + + if state["count"] == 0 or duration_seconds < self.min_duration_per_batch: + # Nothing to persist + self.current_state = None + self.save_state() + + """ + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + """ + + return + + end_time = datetime.now().isoformat() + + count_per_second = state["count"] / duration_seconds + + try: + self._insert_batch( + state["counting_date"], + state["batch_number"], + state["count"], + state["start_time"], + end_time, + ) + self.logger.info( + "Batch #%s ended | count=%s | duration=%s | cps=%s", + state["batch_number"], + state["count"], + duration, + count_per_second + ) + except Exception as exc: + self.logger.error("Failed to persist batch: %s", exc) + # Leave state intact so we can retry on next timeout + return + + self.current_state = None + self.save_state() + """ + if self.batch_timer: + self.batch_timer.cancel() + self.batch_timer = None + """ + + def _insert_batch(self, counting_date, batch_number, count, start_time, end_time): + """Atomic insert into batches + upsert daily summary.""" + cur = self.db.cursor() + + cur.execute( + """ + INSERT INTO batches + (counting_date, batch_number, camera_name, object_label, count, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + counting_date, + batch_number, + self.camera_name, + self.object_label, + count, + start_time, + end_time, + ), + ) + + cur.execute( + """ + INSERT INTO daily_summaries + (counting_date, camera_name, object_label, total_count, total_batches) + VALUES (?, ?, ?, ?, 1) + ON CONFLICT(counting_date, camera_name, object_label) + DO UPDATE SET + total_count = total_count + excluded.total_count, + total_batches = total_batches + excluded.total_batches, + updated_at = CURRENT_TIMESTAMP + """, + (counting_date, self.camera_name, self.object_label, count), + ) + + self.db.commit() + + # Log running totals for the day + cur.execute( + """ + SELECT total_count, total_batches + FROM daily_summaries + WHERE counting_date = ? AND camera_name = ? AND object_label = ? + """, + (counting_date, self.camera_name, self.object_label), + ) + row = cur.fetchone() + if row: + self.logger.info( + "Daily totals for %s: %s objects across %s batch(es)", + counting_date, + row[0], + row[1], + ) + + # ------------------------------------------------------------------ # + # Cutoff watcher (forces batch end at 17:00 etc.) + # ------------------------------------------------------------------ # + def cutoff_watcher(self): + """Runs every minute to force-close a batch when the business day rolls over.""" + while not self.shutdown_event.is_set(): + time.sleep(60) + with self.state_lock: + if self.current_state is None: + continue + if self.current_state["counting_date"] != self.get_counting_date(): + self.logger.info("Daily cutoff reached – finalizing batch") + self._end_batch_locked() + + # ------------------------------------------------------------------ # + # MQTT callbacks + # ------------------------------------------------------------------ # + def on_connect(self, client, userdata, flags, rc): + if rc == 0: + self.logger.info("MQTT connected to %s:%s", self.mqtt_host, self.mqtt_port) + client.subscribe(self.mqtt_topic) + self.logger.info("Subscribed to %s", self.mqtt_topic) + else: + self.logger.error("MQTT connection failed, code=%s", rc) + + def on_message(self, client, userdata, msg): + try: + payload = json.loads(msg.payload.decode("utf-8")) + after = payload.get("after", {}) + + """ 20260514 For Telenan """ + label = after.get("label") + camera = after.get("camera") + + if camera != self.camera_name: + return + + #if after.get("label") != self.object_label: + if label != self.object_label: + if label == self.batch_label and self.ignore_batch_label: + return + + #if label != self.batch_label and self.ignore_batch_label: + # return + + event_id = after.get("id") + if not event_id: + return + + self.process_detection(event_id, label) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..1698f93 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,14 @@ +services: + counter: + image: python:3.11-slim + working_dir: /app + volumes: + - ./:/app + environment: + - CAMERA_NAME=cam_gudang_utara + - OBJECT_LABEL=ayam-potong + - FRIGATE_MQTT_HOST=mqtt.local + - DAILY_CUTOFF_TIME=17:00 + - BATCH_TIMEOUT_SECONDS=60 + command: > + sh -c "pip install paho-mqtt && python counter_service.py" diff --git a/env.example b/env.example new file mode 100644 index 0000000..6703c72 --- /dev/null +++ b/env.example @@ -0,0 +1,24 @@ +# MQTT broker where Frigate publishes +FRIGATE_MQTT_HOST=192.168.1.10 +FRIGATE_MQTT_PORT=1883 +FRIGATE_MQTT_USER= +FRIGATE_MQTT_PASS= + +# Frigate topic (usually frigate/events) +FRIGATE_MQTT_TOPIC=frigate/events + +# Camera & object to track +CAMERA_NAME=cam_gudang_utara +OBJECT_LABEL=ayam-potong + +# Batch ends after this many seconds with no new detections +BATCH_TIMEOUT_SECONDS=60 + +# Business-day cutoff (HH:MM). Batches running at this time are forced closed. +DAILY_CUTOFF_TIME=17:00 + +# Persistence paths +DB_PATH=./frigate_counter.db +STATE_FILE=./current_batch.json + +LOG_LEVEL=INFO diff --git a/frigate-counter-dashboard.service b/frigate-counter-dashboard.service new file mode 100644 index 0000000..870acec --- /dev/null +++ b/frigate-counter-dashboard.service @@ -0,0 +1,48 @@ +[Unit] +Description=Frigate Counter Dashboard +Documentation=https://github.com/your-repo/frigate-counter +After=network-online.target frigate-counter.service +Wants=network-online.target + +[Service] +Type=simple +User=frigate-counter +Group=frigate-counter + +WorkingDirectory=/opt/frigate-counter + +Environment="PATH=/opt/frigate-counter/venv/bin:/usr/local/bin:/usr/bin:/bin" +Environment="DB_PATH=/opt/frigate-counter/frigate_counter.db" +Environment="DASHBOARD_HOST=0.0.0.0" +Environment="DASHBOARD_PORT=5000" +Environment="FLASK_DEBUG=false" + +# Use Gunicorn for production +ExecStart=/opt/frigate-counter/venv/bin/gunicorn \ + -w 2 \ + -b 0.0.0.0:5000 \ + --access-logfile - \ + --error-logfile - \ + --capture-output \ + --enable-stdio-inheritance \ + dashboard:app + +ExecReload=/bin/kill -HUP $MAINPID + +TimeoutStopSec=30 +KillSignal=SIGTERM + +Restart=on-failure +RestartSec=5 +StartLimitInterval=60s +StartLimitBurst=3 + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/frigate-counter +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/frigate-counter.service b/frigate-counter.service new file mode 100644 index 0000000..a13c9c4 --- /dev/null +++ b/frigate-counter.service @@ -0,0 +1,58 @@ +[Unit] +Description=Frigate Object Batch Counter Service +Documentation=https://github.com/your-repo/frigate-counter +After=network-online.target mosquitto.service +Wants=network-online.target + +[Service] +Type=simple +User=frigate-counter +Group=frigate-counter + +# Working directory where state file and DB live +WorkingDirectory=/opt/frigate-counter + +# Path to your Python virtual environment +Environment="PYTHONPATH=/opt/frigate-counter" +Environment="PATH=/opt/frigate-counter/venv/bin:/usr/local/bin:/usr/bin:/bin" +Environment="CAMERA_NAME=cam_gudang_utara" +Environment="OBJECT_LABEL=ayam-potong" +Environment="FRIGATE_MQTT_HOST=192.168.1.10" +Environment="FRIGATE_MQTT_PORT=1883" +Environment="FRIGATE_MQTT_USER=" +Environment="FRIGATE_MQTT_PASS=" +Environment="FRIGATE_MQTT_TOPIC=frigate/events" +Environment="BATCH_TIMEOUT_SECONDS=60" +Environment="DAILY_CUTOFF_TIME=17:00" +Environment="DB_PATH=/opt/frigate-counter/frigate_counter.db" +Environment="STATE_FILE=/opt/frigate-counter/current_batch.json" +Environment="LOG_LEVEL=INFO" + +# Or use an EnvironmentFile instead of inline variables: +# EnvironmentFile=/opt/frigate-counter/.env + +ExecStart=/opt/frigate-counter/venv/bin/python /opt/frigate-counter/counter_service.py +ExecReload=/bin/kill -HUP $MAINPID + +# Graceful shutdown: give the service time to persist the batch to DB +TimeoutStopSec=30 +KillSignal=SIGTERM + +# Restart policy +Restart=on-failure +RestartSec=5 +StartLimitInterval=60s +StartLimitBurst=3 + +# Security hardening (optional but recommended) +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/frigate-counter +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true + +[Install] +WantedBy=multi-user.target diff --git a/frigate_counter.db b/frigate_counter.db new file mode 100644 index 0000000000000000000000000000000000000000..c1138019f62f3bfa30ae2828bd08913ed96ef7cb GIT binary patch literal 147456 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 literal 0 HcmV?d00001 diff --git a/frigate_counter.sql b/frigate_counter.sql new file mode 100644 index 0000000..ff68c0f --- /dev/null +++ b/frigate_counter.sql @@ -0,0 +1,17 @@ +-- All batches today (based on cutoff logic) +SELECT * +FROM batches +WHERE counting_date = (SELECT counting_date FROM daily_summaries ORDER BY updated_at DESC LIMIT 1) +ORDER BY batch_number; + +-- Daily summary +SELECT counting_date, + total_count AS total_ayam, + total_batches AS jumlah_batch +FROM daily_summaries +ORDER BY counting_date DESC; + +-- Average objects per batch per day +SELECT counting_date, + ROUND(CAST(total_count AS FLOAT) / total_batches, 1) AS avg_per_batch +FROM daily_summaries; diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cd1b86c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=2.3.0 +paho-mqtt>=1.6,<3.0 +gunicorn>=21.0 diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..4bed33d --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,796 @@ + + + + + + 🐔 ZenAI APC Dashboard + + + + + + + +
+
+
+
+
+ +
+
+

ZenAI APC Dashboard

+

Real-time batch counting analytics

+
+
+
+
+ Counting Day +

--

+
+
+
+ Live +
+
+
+
+
+ +
+ +
+ +
+
+ + LIVE + +
+
+
+ +
+ Batch #-- +
+

--

+

Current Batch Count

+
+ -- +
+
+ + +
+
+
+ +
+ Today +
+

--

+

Total Ayam Potong

+
+ -- batches +
+
+ + +
+
+
+ +
+ Yesterday +
+

--

+

Total Ayam Potong

+
+ -- batches +
+
+ + +
+
+
+ +
+ Average +
+

--

+

Per Day

+
+ -- days recorded +
+
+ + +
+
+
+ +
+ Best Day +
+

--

+

--

+
+ Record +
+
+
+ + +
+ +
+
+
+

Daily Trends

+

Object count over time

+
+
+ + + +
+
+
+ +
+
+ + +
+

Quick Stats

+
+
+
+
+ +
+
+

Grand Total

+

All time

+
+
+ -- +
+ +
+
+
+ +
+
+

Total Batches

+

All time

+
+
+ -- +
+ +
+
+
+ +
+
+

Avg per Batch

+

Overall average

+
+
+ -- +
+
+ +
+

Recent Activity

+
+ +
+
+
+
+ + +
+
+
+

Daily Records

+

Click on a row to view batch details

+
+
+ + + + + +
+
+ +
+ + + + + + + + + + + + + + +
DateTotal CountBatchesAvg/BatchStatusAction
+
+
+
+ + + + + + + + diff --git a/templates/dashboard.html.20260506 b/templates/dashboard.html.20260506 new file mode 100644 index 0000000..7f75fba --- /dev/null +++ b/templates/dashboard.html.20260506 @@ -0,0 +1,710 @@ + + + + + + 🐔 Ayam Counter Dashboard + + + + + + + +
+
+
+
+
+ +
+
+

Ayam Counter Dashboard

+

Real-time batch counting analytics

+
+
+
+
+ Counting Day +

--

+
+
+
+ Live +
+
+
+
+
+ +
+ +
+ +
+
+
+ +
+ Today +
+

--

+

Total Ayam Potong

+
+ -- batches +
+
+ + +
+
+
+ +
+ Yesterday +
+

--

+

Total Ayam Potong

+
+ -- batches +
+
+ + +
+
+
+ +
+ Average +
+

--

+

Per Day

+
+ -- days recorded +
+
+ + +
+
+
+ +
+ Best Day +
+

--

+

--

+
+ Record +
+
+
+ + +
+ +
+
+
+

Daily Trends

+

Object count over time

+
+
+ + + +
+
+
+ +
+
+ + +
+

Quick Stats

+
+
+
+
+ +
+
+

Grand Total

+

All time

+
+
+ -- +
+ +
+
+
+ +
+
+

Total Batches

+

All time

+
+
+ -- +
+ +
+
+
+ +
+
+

Avg per Batch

+

Overall average

+
+
+ -- +
+
+ +
+

Recent Activity

+
+ +
+
+
+
+ + +
+
+
+

Daily Records

+

Click on a row to view batch details

+
+
+ + + +
+
+ +
+ + + + + + + + + + + + + + +
DateTotal CountBatchesAvg/BatchStatusAction
+
+
+
+ + + + + + + diff --git a/templates/dashboard.html.20260517 b/templates/dashboard.html.20260517 new file mode 100644 index 0000000..53ee43c --- /dev/null +++ b/templates/dashboard.html.20260517 @@ -0,0 +1,739 @@ + + + + + + 🐔 Ayam Counter Dashboard + + + + + + + +
+
+
+
+
+ +
+
+

Ayam Counter Dashboard

+

Real-time batch counting analytics

+
+
+
+
+ Counting Day +

--

+
+
+
+ Live +
+
+
+
+
+ +
+ +
+ +
+
+
+ +
+ Today +
+

--

+

Total Ayam Potong

+
+ -- batches +
+
+ + +
+
+
+ +
+ Yesterday +
+

--

+

Total Ayam Potong

+
+ -- batches +
+
+ + +
+
+
+ +
+ Average +
+

--

+

Per Day

+
+ -- days recorded +
+
+ + +
+
+
+ +
+ Best Day +
+

--

+

--

+
+ Record +
+
+
+ + +
+ +
+
+
+

Daily Trends

+

Object count over time

+
+
+ + + +
+
+
+ +
+
+ + +
+

Quick Stats

+
+
+
+
+ +
+
+

Grand Total

+

All time

+
+
+ -- +
+ +
+
+
+ +
+
+

Total Batches

+

All time

+
+
+ -- +
+ +
+
+
+ +
+
+

Avg per Batch

+

Overall average

+
+
+ -- +
+
+ +
+

Recent Activity

+
+ +
+
+
+
+ + +
+
+
+

Daily Records

+

Click on a row to view batch details

+
+
+ + + + + +
+
+ +
+ + + + + + + + + + + + + + +
DateTotal CountBatchesAvg/BatchStatusAction
+
+
+
+ + + + + + + +