First Commit

This commit is contained in:
2026-05-19 14:32:46 +07:00
parent 73a8a01048
commit b3d162450d
20 changed files with 6543 additions and 2 deletions
+4 -2
View File
@@ -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
+462
View File
@@ -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/<date>")
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/<date>")
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)
+280
View File
@@ -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/<date>')
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)
+377
View File
@@ -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/<date>')
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/<date>')
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)
+503
View File
@@ -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()
+519
View File
@@ -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()
+464
View File
@@ -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()
+513
View File
@@ -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()
+505
View File
@@ -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()
+507
View File
@@ -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()
+14
View File
@@ -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"
+24
View File
@@ -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
+48
View File
@@ -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
+58
View File
@@ -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
Binary file not shown.
+17
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
flask>=2.3.0
paho-mqtt>=1.6,<3.0
gunicorn>=21.0
+796
View File
@@ -0,0 +1,796 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🐔 ZenAI APC Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
* { font-family: 'Inter', sans-serif; }
.hide-me {
display: none;
}
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.glass-dark {
background: rgba(17, 24, 39, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.gradient-bg-warm {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.gradient-bg-success {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.gradient-bg-gold {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
.gradient-bg-live {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.card-hover {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.15);
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out forwards;
}
.animate-slide-up {
animation: slideUp 0.5s ease-out forwards;
opacity: 0;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
.pulse-dot {
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.table-row {
transition: all 0.2s ease;
}
.table-row:hover {
background-color: rgba(99, 102, 241, 0.05);
}
.batch-badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.count-badge {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c7c7c7;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
.chart-container {
position: relative;
height: 300px;
}
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.btn-export {
background: #2563eb;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.btn-export:hover {
background: #1d4ed8;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- Header -->
<header class="gradient-bg text-white shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="bg-white/20 p-3 rounded-2xl backdrop-blur-sm">
<i class="fas fa-drumstick-bite text-3xl"></i>
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">ZenAI APC Dashboard</h1>
<p class="text-white/80 text-sm mt-1">Real-time batch counting analytics</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="bg-white/10 px-4 py-2 rounded-xl backdrop-blur-sm">
<span class="text-xs text-white/70 uppercase tracking-wider">Counting Day</span>
<p class="font-semibold" id="current-date">--</p>
</div>
<div class="bg-white/10 px-4 py-2 rounded-xl backdrop-blur-sm flex items-center space-x-2">
<div class="w-2 h-2 bg-green-400 rounded-full pulse-dot"></div>
<span class="text-sm font-medium">Live</span>
</div>
</div>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Live Current Batch -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-1 border-2 border-green-400/30 relative overflow-hidden">
<div class="absolute top-0 right-0 p-3">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-green-100 text-green-800 shadow-sm">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1.5 pulse-dot"></span>LIVE
</span>
</div>
<div class="flex items-center justify-between mb-4 mt-2">
<div class="p-3 rounded-xl gradient-bg-live text-white shadow-lg">
<i class="fas fa-bolt text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Batch #<span id="live-batch-number">--</span></span>
</div>
<h3 class="text-4xl font-bold text-gray-900 tracking-tight" id="live-count">--</h3>
<p class="text-sm text-gray-500 mt-1 font-medium">Current Batch Count</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-gray-400" id="live-last-detection">--</span>
</div>
</div>
<!-- Today's Count -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-2">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg text-white">
<i class="fas fa-calendar-day text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Today</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="today-count">--</h3>
<p class="text-sm text-gray-500 mt-1">Total Ayam Potong</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-gray-400" id="today-batches">-- batches</span>
</div>
</div>
<!-- Yesterday -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-3">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg-warm text-white">
<i class="fas fa-history text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Yesterday</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="yesterday-count">--</h3>
<p class="text-sm text-gray-500 mt-1">Total Ayam Potong</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-gray-400" id="yesterday-batches">-- batches</span>
</div>
</div>
<!-- Average -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-4">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg-success text-white">
<i class="fas fa-chart-line text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Average</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="avg-count">--</h3>
<p class="text-sm text-gray-500 mt-1">Per Day</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-gray-400" id="total-days">-- days recorded</span>
</div>
</div>
<!-- Best Day -->
<div class="hide-me glass rounded-2xl p-6 card-hover animate-slide-up stagger-5">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg-gold text-white">
<i class="fas fa-trophy text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Best Day</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="best-count">--</h3>
<p class="text-sm text-gray-500 mt-1" id="best-date">--</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-amber-500 font-medium"><i class="fas fa-star mr-1"></i>Record</span>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Main Chart -->
<div class="lg:col-span-2 glass rounded-2xl p-6 animate-slide-up stagger-3">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-lg font-bold text-gray-900">Daily Trends</h2>
<p class="text-sm text-gray-500">Object count over time</p>
</div>
<div class="flex space-x-2">
<button onclick="loadChartData(7)" class="px-3 py-1 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors chart-filter active" data-days="7">7D</button>
<button onclick="loadChartData(14)" class="px-3 py-1 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors chart-filter" data-days="14">14D</button>
<button onclick="loadChartData(30)" class="px-3 py-1 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors chart-filter" data-days="30">30D</button>
</div>
</div>
<div class="chart-container">
<canvas id="mainChart"></canvas>
</div>
</div>
<!-- Stats Panel -->
<div class="glass rounded-2xl p-6 animate-slide-up stagger-4">
<h2 class="text-lg font-bold text-gray-900 mb-4">Quick Stats</h2>
<div class="space-y-4">
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg gradient-bg flex items-center justify-center text-white">
<i class="fas fa-layer-group"></i>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Grand Total</p>
<p class="text-xs text-gray-500">All time</p>
</div>
</div>
<span class="text-xl font-bold text-gray-900" id="grand-total">--</span>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg gradient-bg-warm flex items-center justify-center text-white">
<i class="fas fa-cubes"></i>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Total Batches</p>
<p class="text-xs text-gray-500">All time</p>
</div>
</div>
<span class="text-xl font-bold text-gray-900" id="grand-batches">--</span>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg gradient-bg-success flex items-center justify-center text-white">
<i class="fas fa-percentage"></i>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Avg per Batch</p>
<p class="text-xs text-gray-500">Overall average</p>
</div>
</div>
<span class="text-xl font-bold text-gray-900" id="avg-per-batch">--</span>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-200">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Recent Activity</h3>
<div id="recent-batches" class="space-y-3 max-h-48 overflow-y-auto">
<!-- Populated by JS -->
</div>
</div>
</div>
</div>
<!-- Daily Detail Table -->
<div class="glass rounded-2xl p-6 animate-slide-up stagger-5">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-lg font-bold text-gray-900">Daily Records</h2>
<p class="text-sm text-gray-500">Click on a row to view batch details</p>
</div>
<div class="flex items-center space-x-2">
<input type="date" id="date-filter" class="px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<button onclick="filterByDate()" class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 transition-colors">
<i class="fas fa-filter mr-1"></i>Filter
</button>
<button onclick="loadAllDates()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors">
Reset
</button>
<!-- Day Detail Export Button -->
<button onclick="exportDayDetail()" class="btn-export">
📥 Export Day CSV
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-left text-xs font-semibold text-gray-500 uppercase tracking-wider border-b border-gray-200">
<th class="pb-3 pl-4">Date</th>
<th class="pb-3">Total Count</th>
<th class="pb-3">Batches</th>
<th class="pb-3">Avg/Batch</th>
<th class="pb-3">Status</th>
<th class="pb-3 pr-4">Action</th>
</tr>
</thead>
<tbody id="daily-table-body">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</main>
<!-- Detail Modal -->
<div id="detail-modal" class="fixed inset-0 z-50 hidden">
<div class="modal-overlay absolute inset-0" onclick="closeModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="glass-dark rounded-2xl p-6 w-full max-w-3xl pointer-events-auto transform transition-all scale-95 opacity-0" id="modal-content">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-white" id="modal-title">Batch Details</h2>
<p class="text-gray-400 text-sm mt-1" id="modal-subtitle">--</p>
</div>
<button onclick="closeModal()" class="text-gray-400 hover:text-white transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white/5 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-white" id="modal-total">--</p>
<p class="text-xs text-gray-400 mt-1">Total Count</p>
</div>
<div class="bg-white/5 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-white" id="modal-batches">--</p>
<p class="text-xs text-gray-400 mt-1">Total Batches</p>
</div>
<div class="bg-white/5 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-white" id="modal-avg">--</p>
<p class="text-xs text-gray-400 mt-1">Avg Duration (min)</p>
</div>
</div>
<div class="overflow-x-auto max-h-96 overflow-y-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs font-semibold text-gray-400 uppercase tracking-wider border-b border-gray-700">
<th class="pb-3 pl-2">Batch #</th>
<th class="pb-3">Count</th>
<th class="pb-3">Start</th>
<th class="pb-3">End</th>
<th class="pb-3">Duration</th>
</tr>
</thead>
<tbody id="modal-table-body" class="text-gray-300">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let mainChart = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadSummary();
loadCurrentBatch(); // <-- initial load
loadChartData(7);
loadRecentBatches();
loadAllDates();
// Update summary every 30 seconds
setInterval(() => {
loadSummary();
loadRecentBatches();
}, 30000);
// Update current batch every 2 seconds for real-time feel
setInterval(() => {
loadCurrentBatch();
}, 2000);
});
async function loadCurrentBatch() {
try {
const res = await fetch('/api/current-batch');
if (!res.ok) throw new Error('No active batch');
const data = await res.json();
document.getElementById('live-count').textContent = data.count.toLocaleString();
document.getElementById('live-batch-number').textContent = data.batch_number || '--';
if (data.last_detection_time) {
const lastDet = new Date(data.last_detection_time);
document.getElementById('live-last-detection').textContent =
'Last detection: ' + lastDet.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} else {
document.getElementById('live-last-detection').textContent = 'Waiting for detections...';
}
} catch (err) {
document.getElementById('live-count').textContent = '0';
document.getElementById('live-batch-number').textContent = '--';
document.getElementById('live-last-detection').textContent = 'No active batch';
}
}
async function loadSummary() {
try {
const res = await fetch('/api/summary');
const data = await res.json();
document.getElementById('current-date').textContent = data.today.date;
document.getElementById('today-count').textContent = data.today.total_count.toLocaleString();
document.getElementById('today-batches').textContent = `${data.today.total_batches} batches`;
document.getElementById('yesterday-count').textContent = data.yesterday.total_count.toLocaleString();
document.getElementById('yesterday-batches').textContent = `${data.yesterday.total_batches} batches`;
document.getElementById('avg-count').textContent = Number(data.average_per_day).toLocaleString();
document.getElementById('total-days').textContent = `${data.all_time.total_days} days recorded`;
document.getElementById('best-count').textContent = data.best_day.count.toLocaleString();
document.getElementById('best-date').textContent = data.best_day.date || '--';
document.getElementById('grand-total').textContent = data.all_time.grand_total.toLocaleString();
document.getElementById('grand-batches').textContent = data.all_time.grand_batches.toLocaleString();
const avgBatch = data.all_time.grand_batches > 0
? (data.all_time.grand_total / data.all_time.grand_batches).toFixed(1)
: 0;
document.getElementById('avg-per-batch').textContent = avgBatch;
} catch (err) {
console.error('Failed to load summary:', err);
}
}
async function loadChartData(days) {
// Update active button
document.querySelectorAll('.chart-filter').forEach(btn => {
btn.classList.remove('bg-indigo-600', 'text-white');
btn.classList.add('bg-gray-100', 'text-gray-700');
if (parseInt(btn.dataset.days) === days) {
btn.classList.remove('bg-gray-100', 'text-gray-700');
btn.classList.add('bg-indigo-600', 'text-white');
}
});
try {
const res = await fetch(`/api/daily-data?days=${days}`);
const data = await res.json();
const labels = data.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('id-ID', { weekday: 'short', day: 'numeric', month: 'short' });
});
const counts = data.map(d => d.total_count);
const batches = data.map(d => d.total_batches);
if (mainChart) {
mainChart.destroy();
}
const ctx = document.getElementById('mainChart').getContext('2d');
mainChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Total Count',
data: counts,
backgroundColor: 'rgba(102, 126, 234, 0.8)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 2,
borderRadius: 8,
yAxisID: 'y'
},
{
label: 'Batches',
data: batches,
type: 'line',
borderColor: 'rgba(245, 87, 108, 1)',
backgroundColor: 'rgba(245, 87, 108, 0.1)',
borderWidth: 3,
pointRadius: 4,
pointBackgroundColor: 'rgba(245, 87, 108, 1)',
tension: 0.4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 20,
font: { size: 12 }
}
},
tooltip: {
backgroundColor: 'rgba(17, 24, 39, 0.9)',
padding: 12,
cornerRadius: 8,
titleFont: { size: 13 },
bodyFont: { size: 12 }
}
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 11 } }
},
y: {
type: 'linear',
display: true,
position: 'left',
grid: { color: 'rgba(0,0,0,0.05)' },
title: { display: true, text: 'Count' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { display: false },
title: { display: true, text: 'Batches' }
}
}
}
});
} catch (err) {
console.error('Failed to load chart:', err);
}
}
async function loadRecentBatches() {
try {
const res = await fetch('/api/recent-batches?limit=5');
const batches = await res.json();
const container = document.getElementById('recent-batches');
container.innerHTML = batches.map(b => `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl text-sm">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-lg batch-badge flex items-center justify-center text-white text-xs font-bold">
#${b.batch_number}
</div>
<div>
<p class="font-medium text-gray-900">${b.date}</p>
<p class="text-xs text-gray-500">${formatTime(b.start_time)} - ${formatTime(b.end_time)}</p>
</div>
</div>
<div class="text-right">
<p class="font-bold text-gray-900">${b.count}</p>
<p class="text-xs text-gray-500">${b.duration_minutes} min</p>
</div>
</div>
`).join('');
} catch (err) {
console.error('Failed to load recent batches:', err);
}
}
async function loadAllDates() {
try {
const res = await fetch('/api/available-dates');
const dates = await res.json();
renderDailyTable(dates);
} catch (err) {
console.error('Failed to load dates:', err);
}
}
function renderDailyTable(dates) {
const tbody = document.getElementById('daily-table-body');
if (dates.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="py-8 text-center text-gray-500">
<i class="fas fa-inbox text-4xl mb-2 text-gray-300"></i>
<p>No data available</p>
</td>
</tr>
`;
return;
}
tbody.innerHTML = dates.map(d => {
const isToday = d.date === document.getElementById('current-date').textContent;
const avgPerBatch = d.total_batches > 0 ? (d.total_count / d.total_batches).toFixed(1) : 0;
return `
<tr class="table-row border-b border-gray-100 cursor-pointer" onclick="showDayDetail('${d.date}')">
<td class="py-4 pl-4">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl ${isToday ? 'gradient-bg' : 'bg-gray-100'} flex items-center justify-center ${isToday ? 'text-white' : 'text-gray-600'}">
<i class="fas fa-calendar"></i>
</div>
<div>
<p class="font-medium text-gray-900">${d.date}</p>
${isToday ? '<span class="text-xs text-indigo-600 font-medium">Today</span>' : ''}
</div>
</div>
</td>
<td class="py-4">
<span class="text-lg font-bold text-gray-900">${d.total_count.toLocaleString()}</span>
</td>
<td class="py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
${d.total_batches} batch${d.total_batches !== 1 ? 'es' : ''}
</span>
</td>
<td class="py-4 text-gray-600">${avgPerBatch}</td>
<td class="py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isToday ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">
${isToday ? '<span class="w-1.5 h-1.5 bg-green-500 rounded-full mr-1.5 pulse-dot"></span>Active' : 'Completed'}
</span>
</td>
<td class="py-4 pr-4">
<button class="text-indigo-600 hover:text-indigo-800 text-sm font-medium">
<i class="fas fa-eye mr-1"></i>View
</button>
</td>
</tr>
`;
}).join('');
}
async function showDayDetail(date) {
try {
const res = await fetch(`/api/day-detail/${date}`);
const data = await res.json();
document.getElementById('modal-title').textContent = `Batch Details - ${date}`;
document.getElementById('modal-subtitle').textContent = `${data.total_batches} batches, ${data.total_count} total objects`;
document.getElementById('modal-total').textContent = data.total_count.toLocaleString();
document.getElementById('modal-batches').textContent = data.total_batches;
document.getElementById('modal-avg').textContent = data.avg_duration_minutes;
const tbody = document.getElementById('modal-table-body');
tbody.innerHTML = data.batches.map(b => `
<tr class="border-b border-gray-700/50 hover:bg-white/5 transition-colors">
<td class="py-3 pl-2">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg batch-badge text-white text-xs font-bold">
${b.batch_number}
</span>
</td>
<td class="py-3 font-medium text-white">${b.count}</td>
<td class="py-3 text-gray-400">${formatTime(b.start_time)}</td>
<td class="py-3 text-gray-400">${formatTime(b.end_time)}</td>
<td class="py-3 text-gray-400">${b.duration_minutes} min</td>
</tr>
`).join('');
const modal = document.getElementById('detail-modal');
const content = document.getElementById('modal-content');
modal.classList.remove('hidden');
setTimeout(() => {
content.classList.remove('scale-95', 'opacity-0');
content.classList.add('scale-100', 'opacity-100');
}, 10);
} catch (err) {
console.error('Failed to load day detail:', err);
}
}
function closeModal() {
const modal = document.getElementById('detail-modal');
const content = document.getElementById('modal-content');
content.classList.remove('scale-100', 'opacity-100');
content.classList.add('scale-95', 'opacity-0');
setTimeout(() => {
modal.classList.add('hidden');
}, 200);
}
function filterByDate() {
const date = document.getElementById('date-filter').value;
if (!date) return;
// Filter the table to show only this date
fetch('/api/available-dates')
.then(r => r.json())
.then(dates => {
const filtered = dates.filter(d => d.date === date);
renderDailyTable(filtered);
});
}
function formatTime(isoString) {
if (!isoString) return '--';
const date = new Date(isoString);
return date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
}
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
</script>
<script>
function exportDayDetail() {
// Use the currently selected/viewed date
//const date = document.getElementById('date-filter')?.dataset.date;
const date = document.getElementById('date-filter')?.value;
if (!date) {
alert('Please select a date first');
return;
}
window.location.href = `/api/export-day-csv/${date}`;
}
</script>
</body>
</html>
+710
View File
@@ -0,0 +1,710 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🐔 Ayam Counter Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
* { font-family: 'Inter', sans-serif; }
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.glass-dark {
background: rgba(17, 24, 39, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.gradient-bg-warm {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.gradient-bg-success {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.gradient-bg-gold {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
.card-hover {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.15);
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out forwards;
}
.animate-slide-up {
animation: slideUp 0.5s ease-out forwards;
opacity: 0;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
.pulse-dot {
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.table-row {
transition: all 0.2s ease;
}
.table-row:hover {
background-color: rgba(99, 102, 241, 0.05);
}
.batch-badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.count-badge {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c7c7c7;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
.chart-container {
position: relative;
height: 300px;
}
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- Header -->
<header class="gradient-bg text-white shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="bg-white/20 p-3 rounded-2xl backdrop-blur-sm">
<i class="fas fa-drumstick-bite text-3xl"></i>
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">Ayam Counter Dashboard</h1>
<p class="text-white/80 text-sm mt-1">Real-time batch counting analytics</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="bg-white/10 px-4 py-2 rounded-xl backdrop-blur-sm">
<span class="text-xs text-white/70 uppercase tracking-wider">Counting Day</span>
<p class="font-semibold" id="current-date">--</p>
</div>
<div class="bg-white/10 px-4 py-2 rounded-xl backdrop-blur-sm flex items-center space-x-2">
<div class="w-2 h-2 bg-green-400 rounded-full pulse-dot"></div>
<span class="text-sm font-medium">Live</span>
</div>
</div>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Today's Count -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-1">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg text-white">
<i class="fas fa-calendar-day text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Today</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="today-count">--</h3>
<p class="text-sm text-gray-500 mt-1">Total Ayam Potong</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-gray-400" id="today-batches">-- batches</span>
</div>
</div>
<!-- Yesterday -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-2">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg-warm text-white">
<i class="fas fa-history text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Yesterday</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="yesterday-count">--</h3>
<p class="text-sm text-gray-500 mt-1">Total Ayam Potong</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-gray-400" id="yesterday-batches">-- batches</span>
</div>
</div>
<!-- Average -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-3">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg-success text-white">
<i class="fas fa-chart-line text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Average</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="avg-count">--</h3>
<p class="text-sm text-gray-500 mt-1">Per Day</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-gray-400" id="total-days">-- days recorded</span>
</div>
</div>
<!-- Best Day -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-4">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg-gold text-white">
<i class="fas fa-trophy text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Best Day</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="best-count">--</h3>
<p class="text-sm text-gray-500 mt-1" id="best-date">--</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-amber-500 font-medium"><i class="fas fa-star mr-1"></i>Record</span>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Main Chart -->
<div class="lg:col-span-2 glass rounded-2xl p-6 animate-slide-up stagger-3">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-lg font-bold text-gray-900">Daily Trends</h2>
<p class="text-sm text-gray-500">Object count over time</p>
</div>
<div class="flex space-x-2">
<button onclick="loadChartData(7)" class="px-3 py-1 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors chart-filter active" data-days="7">7D</button>
<button onclick="loadChartData(14)" class="px-3 py-1 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors chart-filter" data-days="14">14D</button>
<button onclick="loadChartData(30)" class="px-3 py-1 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors chart-filter" data-days="30">30D</button>
</div>
</div>
<div class="chart-container">
<canvas id="mainChart"></canvas>
</div>
</div>
<!-- Stats Panel -->
<div class="glass rounded-2xl p-6 animate-slide-up stagger-4">
<h2 class="text-lg font-bold text-gray-900 mb-4">Quick Stats</h2>
<div class="space-y-4">
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg gradient-bg flex items-center justify-center text-white">
<i class="fas fa-layer-group"></i>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Grand Total</p>
<p class="text-xs text-gray-500">All time</p>
</div>
</div>
<span class="text-xl font-bold text-gray-900" id="grand-total">--</span>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg gradient-bg-warm flex items-center justify-center text-white">
<i class="fas fa-cubes"></i>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Total Batches</p>
<p class="text-xs text-gray-500">All time</p>
</div>
</div>
<span class="text-xl font-bold text-gray-900" id="grand-batches">--</span>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg gradient-bg-success flex items-center justify-center text-white">
<i class="fas fa-percentage"></i>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Avg per Batch</p>
<p class="text-xs text-gray-500">Overall average</p>
</div>
</div>
<span class="text-xl font-bold text-gray-900" id="avg-per-batch">--</span>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-200">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Recent Activity</h3>
<div id="recent-batches" class="space-y-3 max-h-48 overflow-y-auto">
<!-- Populated by JS -->
</div>
</div>
</div>
</div>
<!-- Daily Detail Table -->
<div class="glass rounded-2xl p-6 animate-slide-up stagger-5">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-lg font-bold text-gray-900">Daily Records</h2>
<p class="text-sm text-gray-500">Click on a row to view batch details</p>
</div>
<div class="flex items-center space-x-2">
<input type="date" id="date-filter" class="px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<button onclick="filterByDate()" class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 transition-colors">
<i class="fas fa-filter mr-1"></i>Filter
</button>
<button onclick="loadAllDates()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors">
Reset
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-left text-xs font-semibold text-gray-500 uppercase tracking-wider border-b border-gray-200">
<th class="pb-3 pl-4">Date</th>
<th class="pb-3">Total Count</th>
<th class="pb-3">Batches</th>
<th class="pb-3">Avg/Batch</th>
<th class="pb-3">Status</th>
<th class="pb-3 pr-4">Action</th>
</tr>
</thead>
<tbody id="daily-table-body">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</main>
<!-- Detail Modal -->
<div id="detail-modal" class="fixed inset-0 z-50 hidden">
<div class="modal-overlay absolute inset-0" onclick="closeModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="glass-dark rounded-2xl p-6 w-full max-w-3xl pointer-events-auto transform transition-all scale-95 opacity-0" id="modal-content">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-white" id="modal-title">Batch Details</h2>
<p class="text-gray-400 text-sm mt-1" id="modal-subtitle">--</p>
</div>
<button onclick="closeModal()" class="text-gray-400 hover:text-white transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white/5 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-white" id="modal-total">--</p>
<p class="text-xs text-gray-400 mt-1">Total Count</p>
</div>
<div class="bg-white/5 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-white" id="modal-batches">--</p>
<p class="text-xs text-gray-400 mt-1">Total Batches</p>
</div>
<div class="bg-white/5 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-white" id="modal-avg">--</p>
<p class="text-xs text-gray-400 mt-1">Avg Duration (min)</p>
</div>
</div>
<div class="overflow-x-auto max-h-96 overflow-y-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs font-semibold text-gray-400 uppercase tracking-wider border-b border-gray-700">
<th class="pb-3 pl-2">Batch #</th>
<th class="pb-3">Count</th>
<th class="pb-3">Start</th>
<th class="pb-3">End</th>
<th class="pb-3">Duration</th>
</tr>
</thead>
<tbody id="modal-table-body" class="text-gray-300">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let mainChart = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadSummary();
loadChartData(7);
loadRecentBatches();
loadAllDates();
// Update every 30 seconds
setInterval(() => {
loadSummary();
loadRecentBatches();
}, 30000);
});
async function loadSummary() {
try {
const res = await fetch('/api/summary');
const data = await res.json();
document.getElementById('current-date').textContent = data.today.date;
document.getElementById('today-count').textContent = data.today.total_count.toLocaleString();
document.getElementById('today-batches').textContent = `${data.today.total_batches} batches`;
document.getElementById('yesterday-count').textContent = data.yesterday.total_count.toLocaleString();
document.getElementById('yesterday-batches').textContent = `${data.yesterday.total_batches} batches`;
document.getElementById('avg-count').textContent = Number(data.average_per_day).toLocaleString();
document.getElementById('total-days').textContent = `${data.all_time.total_days} days recorded`;
document.getElementById('best-count').textContent = data.best_day.count.toLocaleString();
document.getElementById('best-date').textContent = data.best_day.date || '--';
document.getElementById('grand-total').textContent = data.all_time.grand_total.toLocaleString();
document.getElementById('grand-batches').textContent = data.all_time.grand_batches.toLocaleString();
const avgBatch = data.all_time.grand_batches > 0
? (data.all_time.grand_total / data.all_time.grand_batches).toFixed(1)
: 0;
document.getElementById('avg-per-batch').textContent = avgBatch;
} catch (err) {
console.error('Failed to load summary:', err);
}
}
async function loadChartData(days) {
// Update active button
document.querySelectorAll('.chart-filter').forEach(btn => {
btn.classList.remove('bg-indigo-600', 'text-white');
btn.classList.add('bg-gray-100', 'text-gray-700');
if (parseInt(btn.dataset.days) === days) {
btn.classList.remove('bg-gray-100', 'text-gray-700');
btn.classList.add('bg-indigo-600', 'text-white');
}
});
try {
const res = await fetch(`/api/daily-data?days=${days}`);
const data = await res.json();
const labels = data.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('id-ID', { weekday: 'short', day: 'numeric', month: 'short' });
});
const counts = data.map(d => d.total_count);
const batches = data.map(d => d.total_batches);
if (mainChart) {
mainChart.destroy();
}
const ctx = document.getElementById('mainChart').getContext('2d');
mainChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Total Count',
data: counts,
backgroundColor: 'rgba(102, 126, 234, 0.8)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 2,
borderRadius: 8,
yAxisID: 'y'
},
{
label: 'Batches',
data: batches,
type: 'line',
borderColor: 'rgba(245, 87, 108, 1)',
backgroundColor: 'rgba(245, 87, 108, 0.1)',
borderWidth: 3,
pointRadius: 4,
pointBackgroundColor: 'rgba(245, 87, 108, 1)',
tension: 0.4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 20,
font: { size: 12 }
}
},
tooltip: {
backgroundColor: 'rgba(17, 24, 39, 0.9)',
padding: 12,
cornerRadius: 8,
titleFont: { size: 13 },
bodyFont: { size: 12 }
}
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 11 } }
},
y: {
type: 'linear',
display: true,
position: 'left',
grid: { color: 'rgba(0,0,0,0.05)' },
title: { display: true, text: 'Count' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { display: false },
title: { display: true, text: 'Batches' }
}
}
}
});
} catch (err) {
console.error('Failed to load chart:', err);
}
}
async function loadRecentBatches() {
try {
const res = await fetch('/api/recent-batches?limit=5');
const batches = await res.json();
const container = document.getElementById('recent-batches');
container.innerHTML = batches.map(b => `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl text-sm">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-lg batch-badge flex items-center justify-center text-white text-xs font-bold">
#${b.batch_number}
</div>
<div>
<p class="font-medium text-gray-900">${b.date}</p>
<p class="text-xs text-gray-500">${formatTime(b.start_time)} - ${formatTime(b.end_time)}</p>
</div>
</div>
<div class="text-right">
<p class="font-bold text-gray-900">${b.count}</p>
<p class="text-xs text-gray-500">${b.duration_minutes} min</p>
</div>
</div>
`).join('');
} catch (err) {
console.error('Failed to load recent batches:', err);
}
}
async function loadAllDates() {
try {
const res = await fetch('/api/available-dates');
const dates = await res.json();
renderDailyTable(dates);
} catch (err) {
console.error('Failed to load dates:', err);
}
}
function renderDailyTable(dates) {
const tbody = document.getElementById('daily-table-body');
if (dates.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="py-8 text-center text-gray-500">
<i class="fas fa-inbox text-4xl mb-2 text-gray-300"></i>
<p>No data available</p>
</td>
</tr>
`;
return;
}
tbody.innerHTML = dates.map(d => {
const isToday = d.date === document.getElementById('current-date').textContent;
const avgPerBatch = d.total_batches > 0 ? (d.total_count / d.total_batches).toFixed(1) : 0;
return `
<tr class="table-row border-b border-gray-100 cursor-pointer" onclick="showDayDetail('${d.date}')">
<td class="py-4 pl-4">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl ${isToday ? 'gradient-bg' : 'bg-gray-100'} flex items-center justify-center ${isToday ? 'text-white' : 'text-gray-600'}">
<i class="fas fa-calendar"></i>
</div>
<div>
<p class="font-medium text-gray-900">${d.date}</p>
${isToday ? '<span class="text-xs text-indigo-600 font-medium">Today</span>' : ''}
</div>
</div>
</td>
<td class="py-4">
<span class="text-lg font-bold text-gray-900">${d.total_count.toLocaleString()}</span>
</td>
<td class="py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
${d.total_batches} batch${d.total_batches !== 1 ? 'es' : ''}
</span>
</td>
<td class="py-4 text-gray-600">${avgPerBatch}</td>
<td class="py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isToday ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">
${isToday ? '<span class="w-1.5 h-1.5 bg-green-500 rounded-full mr-1.5 pulse-dot"></span>Active' : 'Completed'}
</span>
</td>
<td class="py-4 pr-4">
<button class="text-indigo-600 hover:text-indigo-800 text-sm font-medium">
<i class="fas fa-eye mr-1"></i>View
</button>
</td>
</tr>
`;
}).join('');
}
async function showDayDetail(date) {
try {
const res = await fetch(`/api/day-detail/${date}`);
const data = await res.json();
document.getElementById('modal-title').textContent = `Batch Details - ${date}`;
document.getElementById('modal-subtitle').textContent = `${data.total_batches} batches, ${data.total_count} total objects`;
document.getElementById('modal-total').textContent = data.total_count.toLocaleString();
document.getElementById('modal-batches').textContent = data.total_batches;
document.getElementById('modal-avg').textContent = data.avg_duration_minutes;
const tbody = document.getElementById('modal-table-body');
tbody.innerHTML = data.batches.map(b => `
<tr class="border-b border-gray-700/50 hover:bg-white/5 transition-colors">
<td class="py-3 pl-2">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg batch-badge text-white text-xs font-bold">
${b.batch_number}
</span>
</td>
<td class="py-3 font-medium text-white">${b.count}</td>
<td class="py-3 text-gray-400">${formatTime(b.start_time)}</td>
<td class="py-3 text-gray-400">${formatTime(b.end_time)}</td>
<td class="py-3 text-gray-400">${b.duration_minutes} min</td>
</tr>
`).join('');
const modal = document.getElementById('detail-modal');
const content = document.getElementById('modal-content');
modal.classList.remove('hidden');
setTimeout(() => {
content.classList.remove('scale-95', 'opacity-0');
content.classList.add('scale-100', 'opacity-100');
}, 10);
} catch (err) {
console.error('Failed to load day detail:', err);
}
}
function closeModal() {
const modal = document.getElementById('detail-modal');
const content = document.getElementById('modal-content');
content.classList.remove('scale-100', 'opacity-100');
content.classList.add('scale-95', 'opacity-0');
setTimeout(() => {
modal.classList.add('hidden');
}, 200);
}
function filterByDate() {
const date = document.getElementById('date-filter').value;
if (!date) return;
// Filter the table to show only this date
fetch('/api/available-dates')
.then(r => r.json())
.then(dates => {
const filtered = dates.filter(d => d.date === date);
renderDailyTable(filtered);
});
}
function formatTime(isoString) {
if (!isoString) return '--';
const date = new Date(isoString);
return date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
}
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>
+739
View File
@@ -0,0 +1,739 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🐔 Ayam Counter Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
* { font-family: 'Inter', sans-serif; }
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.glass-dark {
background: rgba(17, 24, 39, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.gradient-bg-warm {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.gradient-bg-success {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.gradient-bg-gold {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
.card-hover {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.15);
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out forwards;
}
.animate-slide-up {
animation: slideUp 0.5s ease-out forwards;
opacity: 0;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
.pulse-dot {
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.table-row {
transition: all 0.2s ease;
}
.table-row:hover {
background-color: rgba(99, 102, 241, 0.05);
}
.batch-badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.count-badge {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c7c7c7;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
.chart-container {
position: relative;
height: 300px;
}
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.btn-export {
background: #2563eb;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.btn-export:hover {
background: #1d4ed8;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- Header -->
<header class="gradient-bg text-white shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="bg-white/20 p-3 rounded-2xl backdrop-blur-sm">
<i class="fas fa-drumstick-bite text-3xl"></i>
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight">Ayam Counter Dashboard</h1>
<p class="text-white/80 text-sm mt-1">Real-time batch counting analytics</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="bg-white/10 px-4 py-2 rounded-xl backdrop-blur-sm">
<span class="text-xs text-white/70 uppercase tracking-wider">Counting Day</span>
<p class="font-semibold" id="current-date">--</p>
</div>
<div class="bg-white/10 px-4 py-2 rounded-xl backdrop-blur-sm flex items-center space-x-2">
<div class="w-2 h-2 bg-green-400 rounded-full pulse-dot"></div>
<span class="text-sm font-medium">Live</span>
</div>
</div>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Today's Count -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-1">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg text-white">
<i class="fas fa-calendar-day text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Today</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="today-count">--</h3>
<p class="text-sm text-gray-500 mt-1">Total Ayam Potong</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-gray-400" id="today-batches">-- batches</span>
</div>
</div>
<!-- Yesterday -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-2">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg-warm text-white">
<i class="fas fa-history text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Yesterday</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="yesterday-count">--</h3>
<p class="text-sm text-gray-500 mt-1">Total Ayam Potong</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-gray-400" id="yesterday-batches">-- batches</span>
</div>
</div>
<!-- Average -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-3">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg-success text-white">
<i class="fas fa-chart-line text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Average</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="avg-count">--</h3>
<p class="text-sm text-gray-500 mt-1">Per Day</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-gray-400" id="total-days">-- days recorded</span>
</div>
</div>
<!-- Best Day -->
<div class="glass rounded-2xl p-6 card-hover animate-slide-up stagger-4">
<div class="flex items-center justify-between mb-4">
<div class="p-3 rounded-xl gradient-bg-gold text-white">
<i class="fas fa-trophy text-xl"></i>
</div>
<span class="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Best Day</span>
</div>
<h3 class="text-3xl font-bold text-gray-900" id="best-count">--</h3>
<p class="text-sm text-gray-500 mt-1" id="best-date">--</p>
<div class="mt-3 flex items-center text-sm">
<span class="text-amber-500 font-medium"><i class="fas fa-star mr-1"></i>Record</span>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Main Chart -->
<div class="lg:col-span-2 glass rounded-2xl p-6 animate-slide-up stagger-3">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-lg font-bold text-gray-900">Daily Trends</h2>
<p class="text-sm text-gray-500">Object count over time</p>
</div>
<div class="flex space-x-2">
<button onclick="loadChartData(7)" class="px-3 py-1 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors chart-filter active" data-days="7">7D</button>
<button onclick="loadChartData(14)" class="px-3 py-1 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors chart-filter" data-days="14">14D</button>
<button onclick="loadChartData(30)" class="px-3 py-1 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors chart-filter" data-days="30">30D</button>
</div>
</div>
<div class="chart-container">
<canvas id="mainChart"></canvas>
</div>
</div>
<!-- Stats Panel -->
<div class="glass rounded-2xl p-6 animate-slide-up stagger-4">
<h2 class="text-lg font-bold text-gray-900 mb-4">Quick Stats</h2>
<div class="space-y-4">
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg gradient-bg flex items-center justify-center text-white">
<i class="fas fa-layer-group"></i>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Grand Total</p>
<p class="text-xs text-gray-500">All time</p>
</div>
</div>
<span class="text-xl font-bold text-gray-900" id="grand-total">--</span>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg gradient-bg-warm flex items-center justify-center text-white">
<i class="fas fa-cubes"></i>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Total Batches</p>
<p class="text-xs text-gray-500">All time</p>
</div>
</div>
<span class="text-xl font-bold text-gray-900" id="grand-batches">--</span>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg gradient-bg-success flex items-center justify-center text-white">
<i class="fas fa-percentage"></i>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Avg per Batch</p>
<p class="text-xs text-gray-500">Overall average</p>
</div>
</div>
<span class="text-xl font-bold text-gray-900" id="avg-per-batch">--</span>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-200">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Recent Activity</h3>
<div id="recent-batches" class="space-y-3 max-h-48 overflow-y-auto">
<!-- Populated by JS -->
</div>
</div>
</div>
</div>
<!-- Daily Detail Table -->
<div class="glass rounded-2xl p-6 animate-slide-up stagger-5">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-lg font-bold text-gray-900">Daily Records</h2>
<p class="text-sm text-gray-500">Click on a row to view batch details</p>
</div>
<div class="flex items-center space-x-2">
<input type="date" id="date-filter" class="px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<button onclick="filterByDate()" class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 transition-colors">
<i class="fas fa-filter mr-1"></i>Filter
</button>
<button onclick="loadAllDates()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors">
Reset
</button>
<!-- Day Detail Export Button -->
<button onclick="exportDayDetail()" class="btn-export">
📥 Export Day CSV
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-left text-xs font-semibold text-gray-500 uppercase tracking-wider border-b border-gray-200">
<th class="pb-3 pl-4">Date</th>
<th class="pb-3">Total Count</th>
<th class="pb-3">Batches</th>
<th class="pb-3">Avg/Batch</th>
<th class="pb-3">Status</th>
<th class="pb-3 pr-4">Action</th>
</tr>
</thead>
<tbody id="daily-table-body">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</main>
<!-- Detail Modal -->
<div id="detail-modal" class="fixed inset-0 z-50 hidden">
<div class="modal-overlay absolute inset-0" onclick="closeModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="glass-dark rounded-2xl p-6 w-full max-w-3xl pointer-events-auto transform transition-all scale-95 opacity-0" id="modal-content">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-white" id="modal-title">Batch Details</h2>
<p class="text-gray-400 text-sm mt-1" id="modal-subtitle">--</p>
</div>
<button onclick="closeModal()" class="text-gray-400 hover:text-white transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white/5 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-white" id="modal-total">--</p>
<p class="text-xs text-gray-400 mt-1">Total Count</p>
</div>
<div class="bg-white/5 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-white" id="modal-batches">--</p>
<p class="text-xs text-gray-400 mt-1">Total Batches</p>
</div>
<div class="bg-white/5 rounded-xl p-4 text-center">
<p class="text-2xl font-bold text-white" id="modal-avg">--</p>
<p class="text-xs text-gray-400 mt-1">Avg Duration (min)</p>
</div>
</div>
<div class="overflow-x-auto max-h-96 overflow-y-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs font-semibold text-gray-400 uppercase tracking-wider border-b border-gray-700">
<th class="pb-3 pl-2">Batch #</th>
<th class="pb-3">Count</th>
<th class="pb-3">Start</th>
<th class="pb-3">End</th>
<th class="pb-3">Duration</th>
</tr>
</thead>
<tbody id="modal-table-body" class="text-gray-300">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let mainChart = null;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadSummary();
loadChartData(7);
loadRecentBatches();
loadAllDates();
// Update every 30 seconds
setInterval(() => {
loadSummary();
loadRecentBatches();
}, 30000);
});
async function loadSummary() {
try {
const res = await fetch('/api/summary');
const data = await res.json();
document.getElementById('current-date').textContent = data.today.date;
document.getElementById('today-count').textContent = data.today.total_count.toLocaleString();
document.getElementById('today-batches').textContent = `${data.today.total_batches} batches`;
document.getElementById('yesterday-count').textContent = data.yesterday.total_count.toLocaleString();
document.getElementById('yesterday-batches').textContent = `${data.yesterday.total_batches} batches`;
document.getElementById('avg-count').textContent = Number(data.average_per_day).toLocaleString();
document.getElementById('total-days').textContent = `${data.all_time.total_days} days recorded`;
document.getElementById('best-count').textContent = data.best_day.count.toLocaleString();
document.getElementById('best-date').textContent = data.best_day.date || '--';
document.getElementById('grand-total').textContent = data.all_time.grand_total.toLocaleString();
document.getElementById('grand-batches').textContent = data.all_time.grand_batches.toLocaleString();
const avgBatch = data.all_time.grand_batches > 0
? (data.all_time.grand_total / data.all_time.grand_batches).toFixed(1)
: 0;
document.getElementById('avg-per-batch').textContent = avgBatch;
} catch (err) {
console.error('Failed to load summary:', err);
}
}
async function loadChartData(days) {
// Update active button
document.querySelectorAll('.chart-filter').forEach(btn => {
btn.classList.remove('bg-indigo-600', 'text-white');
btn.classList.add('bg-gray-100', 'text-gray-700');
if (parseInt(btn.dataset.days) === days) {
btn.classList.remove('bg-gray-100', 'text-gray-700');
btn.classList.add('bg-indigo-600', 'text-white');
}
});
try {
const res = await fetch(`/api/daily-data?days=${days}`);
const data = await res.json();
const labels = data.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('id-ID', { weekday: 'short', day: 'numeric', month: 'short' });
});
const counts = data.map(d => d.total_count);
const batches = data.map(d => d.total_batches);
if (mainChart) {
mainChart.destroy();
}
const ctx = document.getElementById('mainChart').getContext('2d');
mainChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Total Count',
data: counts,
backgroundColor: 'rgba(102, 126, 234, 0.8)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 2,
borderRadius: 8,
yAxisID: 'y'
},
{
label: 'Batches',
data: batches,
type: 'line',
borderColor: 'rgba(245, 87, 108, 1)',
backgroundColor: 'rgba(245, 87, 108, 0.1)',
borderWidth: 3,
pointRadius: 4,
pointBackgroundColor: 'rgba(245, 87, 108, 1)',
tension: 0.4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 20,
font: { size: 12 }
}
},
tooltip: {
backgroundColor: 'rgba(17, 24, 39, 0.9)',
padding: 12,
cornerRadius: 8,
titleFont: { size: 13 },
bodyFont: { size: 12 }
}
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 11 } }
},
y: {
type: 'linear',
display: true,
position: 'left',
grid: { color: 'rgba(0,0,0,0.05)' },
title: { display: true, text: 'Count' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { display: false },
title: { display: true, text: 'Batches' }
}
}
}
});
} catch (err) {
console.error('Failed to load chart:', err);
}
}
async function loadRecentBatches() {
try {
const res = await fetch('/api/recent-batches?limit=5');
const batches = await res.json();
const container = document.getElementById('recent-batches');
container.innerHTML = batches.map(b => `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl text-sm">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-lg batch-badge flex items-center justify-center text-white text-xs font-bold">
#${b.batch_number}
</div>
<div>
<p class="font-medium text-gray-900">${b.date}</p>
<p class="text-xs text-gray-500">${formatTime(b.start_time)} - ${formatTime(b.end_time)}</p>
</div>
</div>
<div class="text-right">
<p class="font-bold text-gray-900">${b.count}</p>
<p class="text-xs text-gray-500">${b.duration_minutes} min</p>
</div>
</div>
`).join('');
} catch (err) {
console.error('Failed to load recent batches:', err);
}
}
async function loadAllDates() {
try {
const res = await fetch('/api/available-dates');
const dates = await res.json();
renderDailyTable(dates);
} catch (err) {
console.error('Failed to load dates:', err);
}
}
function renderDailyTable(dates) {
const tbody = document.getElementById('daily-table-body');
if (dates.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="py-8 text-center text-gray-500">
<i class="fas fa-inbox text-4xl mb-2 text-gray-300"></i>
<p>No data available</p>
</td>
</tr>
`;
return;
}
tbody.innerHTML = dates.map(d => {
const isToday = d.date === document.getElementById('current-date').textContent;
const avgPerBatch = d.total_batches > 0 ? (d.total_count / d.total_batches).toFixed(1) : 0;
return `
<tr class="table-row border-b border-gray-100 cursor-pointer" onclick="showDayDetail('${d.date}')">
<td class="py-4 pl-4">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl ${isToday ? 'gradient-bg' : 'bg-gray-100'} flex items-center justify-center ${isToday ? 'text-white' : 'text-gray-600'}">
<i class="fas fa-calendar"></i>
</div>
<div>
<p class="font-medium text-gray-900">${d.date}</p>
${isToday ? '<span class="text-xs text-indigo-600 font-medium">Today</span>' : ''}
</div>
</div>
</td>
<td class="py-4">
<span class="text-lg font-bold text-gray-900">${d.total_count.toLocaleString()}</span>
</td>
<td class="py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
${d.total_batches} batch${d.total_batches !== 1 ? 'es' : ''}
</span>
</td>
<td class="py-4 text-gray-600">${avgPerBatch}</td>
<td class="py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isToday ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">
${isToday ? '<span class="w-1.5 h-1.5 bg-green-500 rounded-full mr-1.5 pulse-dot"></span>Active' : 'Completed'}
</span>
</td>
<td class="py-4 pr-4">
<button class="text-indigo-600 hover:text-indigo-800 text-sm font-medium">
<i class="fas fa-eye mr-1"></i>View
</button>
</td>
</tr>
`;
}).join('');
}
async function showDayDetail(date) {
try {
const res = await fetch(`/api/day-detail/${date}`);
const data = await res.json();
document.getElementById('modal-title').textContent = `Batch Details - ${date}`;
document.getElementById('modal-subtitle').textContent = `${data.total_batches} batches, ${data.total_count} total objects`;
document.getElementById('modal-total').textContent = data.total_count.toLocaleString();
document.getElementById('modal-batches').textContent = data.total_batches;
document.getElementById('modal-avg').textContent = data.avg_duration_minutes;
const tbody = document.getElementById('modal-table-body');
tbody.innerHTML = data.batches.map(b => `
<tr class="border-b border-gray-700/50 hover:bg-white/5 transition-colors">
<td class="py-3 pl-2">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg batch-badge text-white text-xs font-bold">
${b.batch_number}
</span>
</td>
<td class="py-3 font-medium text-white">${b.count}</td>
<td class="py-3 text-gray-400">${formatTime(b.start_time)}</td>
<td class="py-3 text-gray-400">${formatTime(b.end_time)}</td>
<td class="py-3 text-gray-400">${b.duration_minutes} min</td>
</tr>
`).join('');
const modal = document.getElementById('detail-modal');
const content = document.getElementById('modal-content');
modal.classList.remove('hidden');
setTimeout(() => {
content.classList.remove('scale-95', 'opacity-0');
content.classList.add('scale-100', 'opacity-100');
}, 10);
} catch (err) {
console.error('Failed to load day detail:', err);
}
}
function closeModal() {
const modal = document.getElementById('detail-modal');
const content = document.getElementById('modal-content');
content.classList.remove('scale-100', 'opacity-100');
content.classList.add('scale-95', 'opacity-0');
setTimeout(() => {
modal.classList.add('hidden');
}, 200);
}
function filterByDate() {
const date = document.getElementById('date-filter').value;
if (!date) return;
// Filter the table to show only this date
fetch('/api/available-dates')
.then(r => r.json())
.then(dates => {
const filtered = dates.filter(d => d.date === date);
renderDailyTable(filtered);
});
}
function formatTime(isoString) {
if (!isoString) return '--';
const date = new Date(isoString);
return date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
}
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
</script>
<script>
function exportDayDetail() {
// Use the currently selected/viewed date
//const date = document.getElementById('date-filter')?.dataset.date;
const date = document.getElementById('date-filter')?.value;
if (!date) {
alert('Please select a date first');
return;
}
window.location.href = `/api/export-day-csv/${date}`;
}
</script>
</body>
</html>