From 7677e11faecae4062aa844fff051f8ba9e22078b Mon Sep 17 00:00:00 2001 From: dsutanto Date: Sat, 20 Jun 2026 20:37:25 +0700 Subject: [PATCH] Update from 209 test device --- counter_dashboard.py | 38 +- counter_dashboard.py.20260605 | 462 ++++++++ counter_service.py | 581 +--------- counter_service.py-zone_check | 585 ++++++++++ counter_service.py.20260604 | 526 +++++++++ counter_service.py.20260609 | 552 +++++++++ counter_service.py.20260612 | 580 ++++++++++ counter_service.py.with-line | 588 ++++++++++ frigate_counter.db | Bin 249856 -> 368640 bytes templates/dashboard.html | 1787 ++++++++++++++++------------- templates/dashboard.html.20260608 | 796 +++++++++++++ 11 files changed, 5145 insertions(+), 1350 deletions(-) create mode 100644 counter_dashboard.py.20260605 mode change 100644 => 120000 counter_service.py create mode 100644 counter_service.py-zone_check create mode 100644 counter_service.py.20260604 create mode 100644 counter_service.py.20260609 create mode 100644 counter_service.py.20260612 create mode 100644 counter_service.py.with-line create mode 100644 templates/dashboard.html.20260608 diff --git a/counter_dashboard.py b/counter_dashboard.py index 9cd60b6..753de4a 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -77,7 +77,7 @@ def api_current_batch(): "batch_number": None, "counting_date": None, } - ), 404 + ), 200 except Exception as e: return jsonify( { @@ -90,6 +90,42 @@ def api_current_batch(): ), 500 +@app.route("/api/previous-batch") +def api_previous_batch(): + """Get the most recently completed batch.""" + conn = get_db() + cur = conn.cursor() + + cur.execute( + """ + SELECT counting_date, batch_number, count, start_time, end_time, + ROUND( + (julianday(end_time) - julianday(start_time)) * 24 * 60, 1 + ) as duration_minutes + FROM batches + ORDER BY end_time DESC + LIMIT 1 + """ + ) + + row = cur.fetchone() + conn.close() + + if row: + return jsonify( + { + "success": True, + "date": row["counting_date"], + "batch_number": row["batch_number"], + "count": row["count"], + "start_time": row["start_time"], + "end_time": row["end_time"], + "duration_minutes": row["duration_minutes"], + } + ) + return jsonify({"success": False, "error": "No previous batch"}), 200 + + @app.route("/api/summary") def api_summary(): """Get summary statistics for the dashboard cards.""" diff --git a/counter_dashboard.py.20260605 b/counter_dashboard.py.20260605 new file mode 100644 index 0000000..9cd60b6 --- /dev/null +++ b/counter_dashboard.py.20260605 @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +""" +Frigate Counter Dashboard +A beautiful, interactive Flask web app for viewing daily batch counting data. +""" + +import json +import os +import sqlite3 +import csv +from io import StringIO +from datetime import datetime, timedelta + +from flask import Flask, render_template, jsonify, request, Response +from werkzeug.serving import WSGIRequestHandler + + +app = Flask(__name__, template_folder="templates") +app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "frigate-counter-dashboard") + +DB_PATH = os.getenv("DB_PATH", "frigate_counter.db") +CURRENT_BATCH_PATH = os.getenv("CURRENT_BATCH_PATH", "current_batch.json") +CUTOFF_TIME = os.getenv("CUTOFF_TIME", "17:00") + + +# ------------------------------------------------------------------ # +# Database helpers +# ------------------------------------------------------------------ # +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +# def get_counting_date(dt=None, cutoff_str="17:00"): +def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME): + """Replicate the service logic for determining the counting date.""" + if dt is None: + dt = datetime.now() + cutoff = datetime.strptime(cutoff_str, "%H:%M").time() + if dt.time() < cutoff: + return dt.date().isoformat() + return (dt.date() + timedelta(days=1)).isoformat() + + +# ------------------------------------------------------------------ # +# Routes +# ------------------------------------------------------------------ # +@app.route("/") +def index(): + """Main dashboard page.""" + return render_template("dashboard.html") + + +@app.route("/api/current-batch") +def api_current_batch(): + """Get real-time data from the current active batch file.""" + try: + with open(CURRENT_BATCH_PATH, "r") as f: + data = json.load(f) + return jsonify( + { + "success": True, + "counting_date": data.get("counting_date"), + "batch_number": data.get("batch_number"), + "count": data.get("count", 0), + "start_time": data.get("start_time"), + "last_detection_time": data.get("last_detection_time"), + } + ) + except FileNotFoundError: + return jsonify( + { + "success": False, + "error": "No active batch", + "count": 0, + "batch_number": None, + "counting_date": None, + } + ), 404 + except Exception as e: + return jsonify( + { + "success": False, + "error": str(e), + "count": 0, + "batch_number": None, + "counting_date": None, + } + ), 500 + + +@app.route("/api/summary") +def api_summary(): + """Get summary statistics for the dashboard cards.""" + conn = get_db() + cur = conn.cursor() + + # Today's counting date (based on cutoff) + today = get_counting_date() + + # Today's stats + cur.execute( + """ + SELECT COALESCE(total_count, 0) as total_count, + COALESCE(total_batches, 0) as total_batches + FROM daily_summaries + WHERE counting_date = ? + """, + (today,), + ) + today_row = cur.fetchone() + + # Yesterday's stats + yesterday = ( + datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1) + ).isoformat() + cur.execute( + """ + SELECT COALESCE(total_count, 0) as total_count, + COALESCE(total_batches, 0) as total_batches + FROM daily_summaries + WHERE counting_date = ? + """, + (yesterday,), + ) + yesterday_row = cur.fetchone() + + # All-time totals + cur.execute(""" + SELECT COALESCE(SUM(total_count), 0) as grand_total, + COALESCE(SUM(total_batches), 0) as grand_batches, + COUNT(DISTINCT counting_date) as total_days + FROM daily_summaries + """) + all_time = cur.fetchone() + + # Average per day + cur.execute(""" + SELECT ROUND(AVG(total_count), 1) as avg_per_day + FROM daily_summaries + """) + avg = cur.fetchone() + + # Best day + cur.execute(""" + SELECT counting_date, total_count + FROM daily_summaries + ORDER BY total_count DESC + LIMIT 1 + """) + best = cur.fetchone() + + conn.close() + + return jsonify( + { + "today": { + "date": today, + "total_count": today_row["total_count"] if today_row else 0, + "total_batches": today_row["total_batches"] if today_row else 0, + }, + "yesterday": { + "date": yesterday, + "total_count": yesterday_row["total_count"] if yesterday_row else 0, + "total_batches": yesterday_row["total_batches"] if yesterday_row else 0, + }, + "all_time": { + "grand_total": all_time["grand_total"], + "grand_batches": all_time["grand_batches"], + "total_days": all_time["total_days"], + }, + "average_per_day": avg["avg_per_day"] or 0, + "best_day": { + "date": best["counting_date"] if best else None, + "count": best["total_count"] if best else 0, + }, + } + ) + + +@app.route("/api/daily-data") +def api_daily_data(): + """Get daily data for charts and table.""" + days = request.args.get("days", 30, type=int) + date_from = (datetime.now() - timedelta(days=days)).date().isoformat() + + conn = get_db() + cur = conn.cursor() + + # Daily summaries for chart + cur.execute( + """ + SELECT counting_date, total_count, total_batches, + ROUND(CAST(total_count AS FLOAT) / total_batches, 1) as avg_per_batch + FROM daily_summaries + WHERE counting_date >= ? + ORDER BY counting_date ASC + """, + (date_from,), + ) + + daily_data = [] + for row in cur.fetchall(): + daily_data.append( + { + "date": row["counting_date"], + "total_count": row["total_count"], + "total_batches": row["total_batches"], + "avg_per_batch": row["avg_per_batch"] or 0, + } + ) + + conn.close() + return jsonify(daily_data) + + +@app.route("/api/day-detail/") +def api_day_detail(date): + """Get detailed batch information for a specific day.""" + conn = get_db() + cur = conn.cursor() + + # Batches for this day + cur.execute( + """ + SELECT batch_number, count, start_time, end_time, + ROUND( + (julianday(end_time) - julianday(start_time)) * 24 * 60, 1 + ) as duration_minutes + FROM batches + WHERE counting_date = ? + ORDER BY batch_number ASC + """, + (date,), + ) + + batches = [] + total_duration = 0 + for row in cur.fetchall(): + duration = row["duration_minutes"] or 0 + total_duration += duration + batches.append( + { + "batch_number": row["batch_number"], + "count": row["count"], + "start_time": row["start_time"], + "end_time": row["end_time"], + "duration_minutes": duration, + } + ) + + # Summary for the day + cur.execute( + """ + SELECT total_count, total_batches + FROM daily_summaries + WHERE counting_date = ? + """, + (date,), + ) + summary = cur.fetchone() + + conn.close() + + return jsonify( + { + "date": date, + "total_count": summary["total_count"] if summary else 0, + "total_batches": summary["total_batches"] if summary else 0, + "total_duration_minutes": round(total_duration, 1), + "avg_duration_minutes": round(total_duration / len(batches), 1) + if batches + else 0, + "batches": batches, + } + ) + + +@app.route("/api/recent-batches") +def api_recent_batches(): + """Get the most recent batches across all days.""" + limit = request.args.get("limit", 10, type=int) + + conn = get_db() + cur = conn.cursor() + + cur.execute( + """ + SELECT counting_date, batch_number, count, start_time, end_time, + ROUND( + (julianday(end_time) - julianday(start_time)) * 24 * 60, 1 + ) as duration_minutes + FROM batches + ORDER BY end_time DESC + LIMIT ? + """, + (limit,), + ) + + batches = [] + for row in cur.fetchall(): + batches.append( + { + "date": row["counting_date"], + "batch_number": row["batch_number"], + "count": row["count"], + "start_time": row["start_time"], + "end_time": row["end_time"], + "duration_minutes": row["duration_minutes"] or 0, + } + ) + + conn.close() + return jsonify(batches) + + +@app.route("/api/available-dates") +def api_available_dates(): + """Get list of all available counting dates.""" + conn = get_db() + cur = conn.cursor() + + cur.execute(""" + SELECT counting_date, total_count, total_batches + FROM daily_summaries + ORDER BY counting_date DESC + """) + + dates = [] + for row in cur.fetchall(): + dates.append( + { + "date": row["counting_date"], + "total_count": row["total_count"], + "total_batches": row["total_batches"], + } + ) + + conn.close() + return jsonify(dates) + + +# ------------------------------------------------------------------ # +# CSV Export Routes +# ------------------------------------------------------------------ # +@app.route("/api/export-daily-csv") +def export_daily_csv(): + """Export daily summary records as CSV.""" + days = request.args.get("days", 30, type=int) + date_from = (datetime.now() - timedelta(days=days)).date().isoformat() + + conn = get_db() + cur = conn.cursor() + + cur.execute( + """ + SELECT counting_date, total_count, total_batches, + ROUND(CAST(total_count AS FLOAT) / NULLIF(total_batches, 0), 1) as avg_per_batch + FROM daily_summaries + WHERE counting_date >= ? + ORDER BY counting_date ASC + """, + (date_from,), + ) + + output = StringIO() + writer = csv.writer(output) + writer.writerow(["Date", "Total Count", "Total Batches", "Avg per Batch"]) + + for row in cur.fetchall(): + writer.writerow( + [ + row["counting_date"], + row["total_count"], + row["total_batches"], + row["avg_per_batch"] or 0, + ] + ) + + conn.close() + + csv_data = output.getvalue() + output.close() + + filename = f"daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + return Response( + csv_data, + mimetype="text/csv", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Type": "text/csv; charset=utf-8", + }, + ) + + +@app.route("/api/export-day-csv/") +def export_day_csv(date): + """Export batch details for a specific day as CSV.""" + conn = get_db() + cur = conn.cursor() + + # Batches for this day + cur.execute( + """ + SELECT batch_number, count, start_time, end_time, + ROUND( + (julianday(end_time) - julianday(start_time)) * 24 * 60, 1 + ) as duration_minutes + FROM batches + WHERE counting_date = ? + ORDER BY batch_number ASC + """, + (date,), + ) + + output = StringIO() + writer = csv.writer(output) + writer.writerow( + ["Batch Number", "Count", "Start Time", "End Time", "Duration (min)"] + ) + + for row in cur.fetchall(): + writer.writerow( + [ + row["batch_number"], + row["count"], + row["start_time"], + row["end_time"], + row["duration_minutes"] or 0, + ] + ) + + conn.close() + + csv_data = output.getvalue() + output.close() + + filename = f"day_detail_{date}.csv" + return Response( + csv_data, + mimetype="text/csv", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Type": "text/csv; charset=utf-8", + }, + ) + + +# ------------------------------------------------------------------ # +# Run +# ------------------------------------------------------------------ # +if __name__ == "__main__": + # Suppress Flask's default request log spam + WSGIRequestHandler.protocol_version = "HTTP/1.1" + + port = int(os.getenv("DASHBOARD_PORT", 80)) + host = os.getenv("DASHBOARD_HOST", "0.0.0.0") + debug = os.getenv("FLASK_DEBUG", "false").lower() == "true" + + print(f"🚀 Dashboard running at http://{host}:{port}") + app.run(host=host, port=port, debug=debug) diff --git a/counter_service.py b/counter_service.py deleted file mode 100644 index efaa55f..0000000 --- a/counter_service.py +++ /dev/null @@ -1,580 +0,0 @@ -#!/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() - - # Previous state - self.previous_state = self.current_state - - # Telenan Batch Label - self.ignore_batch_label = False - self.ignore_batch_label_timer = None - self.sleep_after_batch_label_detected = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 10)) - # Sleep none blocking - self.sleep_after_batch_label_timeout = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 10)) - self.sleep_after_batch_label_timer = None - self.sleep_after_batch_label = False - # ------------------------------------------------------------------ # - # 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() - - """ START - Carry last 50 previous counted_ids into next batch """ - counted_ids = [] - if self.previous_state is not None: - try: - counted_ids = self.previous_state["counted_event_ids"][-50:] - except: - counted_ids = [] - - # Print - #self.logger.info(counted_ids) - - self.current_state = { - "counting_date": counting_date, - "batch_number": batch_number, - "count": 0, - "start_time": now, - "last_detection_time": now, - "counted_event_ids": counted_ids, - } - """ END - Carry last 50 previous counted_ids into next batch """ - - """ - 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() - - # Sleep Blocking - #self.logger.info("Batch Label detected. Sleep for %s seconds.", self.sleep_after_batch_label_detected) - #time.sleep(self.sleep_after_batch_label_detected) - - # Sleep Non Blocking - #self._sleep_after_batch_label() - - should_reset_timer = True - - """ - Comment this to remove reset timer - """ - if should_reset_timer: - self._reset_batch_timer() - - def _sleep_after_batch_label(self): - if not self.sleep_after_batch_label_timer: - self.sleep_after_batch_label = True - self.sleep_after_batch_label_timer = threading.Timer(self.sleep_after_batch_label_timeout, self._on_sleep_after_batch_label_timeout) - self.sleep_after_batch_label_timer.daemon = True - self.sleep_after_batch_label_timer.start() - self.logger.info("Sleep (non blocking) after Batch Label for %ss. self.sleep_after_batch_label = %s", self.sleep_after_batch_label_timeout, self.sleep_after_batch_label) - - def _on_sleep_after_batch_label_timeout(self): - self.sleep_after_batch_label_timer = None - self.sleep_after_batch_label = False - self.logger.info("Sleep (non blocking) after Batch Label is done. self.sleep_after_batch_label = %s", self.sleep_after_batch_label) - - 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 - - self.previous_state = self.current_state - 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): - - # Sleep Non Blocking after batch label detected - if self.sleep_after_batch_label: - return - - try: - payload = json.loads(msg.payload.decode("utf-8")) - after = payload.get("after", {}) - - - """ Start - 20260530 for ignore stationary object """ - stationary = after.get("stationary") - if stationary: - return - """ End - 20260530 for ignore stationary object """ - - """ 20260514 For Telenan """ - label = after.get("label") - camera = after.get("camera") - - if camera != self.camera_name: - return - - #if after.get("label") != self.object_label: - if label != self.object_label: - if label == self.batch_label and self.ignore_batch_label: - return - - #if label != self.batch_label and self.ignore_batch_label: - # return - - event_id = after.get("id") - if not event_id: - return - - self.process_detection(event_id, label) - - except json.JSONDecodeError: - self.logger.warning("Received non-JSON payload on %s", msg.topic) - except Exception as exc: - self.logger.exception("Error handling MQTT message: %s", exc) - - # ------------------------------------------------------------------ # - # Run / Shutdown - # ------------------------------------------------------------------ # - def run(self): - # Graceful shutdown on SIGINT / SIGTERM - signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) - signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) - - # Support both paho-mqtt v1 and v2 - try: - self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) - except (AttributeError, TypeError): - self.client = mqtt.Client() - - if self.mqtt_user and self.mqtt_pass: - self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) - - self.client.on_connect = self.on_connect - self.client.on_message = self.on_message - - # Start background cutoff watcher - watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) - watcher.start() - - try: - self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) - self.client.loop_forever() - except Exception as exc: - self.logger.error("MQTT loop error: %s", exc) - finally: - self.shutdown() - - def shutdown(self): - if self.shutdown_event.is_set(): - return - self.logger.info("Shutting down...") - self.shutdown_event.set() - try: - self.client.disconnect() - except Exception: - pass - self.end_batch() - self.db.close() - self.logger.info("Shutdown complete") - - -if __name__ == "__main__": - service = FrigateCounterService() - service.run() diff --git a/counter_service.py b/counter_service.py new file mode 120000 index 0000000..b18d9a7 --- /dev/null +++ b/counter_service.py @@ -0,0 +1 @@ +counter_service.py-zone_check \ No newline at end of file diff --git a/counter_service.py-zone_check b/counter_service.py-zone_check new file mode 100644 index 0000000..64beab4 --- /dev/null +++ b/counter_service.py-zone_check @@ -0,0 +1,585 @@ +#!/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() + + # Previous state + self.previous_state = self.current_state + + # Telenan Batch Label + self.ignore_batch_label = False + self.ignore_batch_label_timer = None + self.sleep_after_batch_label_detected = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 10)) + # Sleep none blocking + self.sleep_after_batch_label_timeout = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 10)) + self.sleep_after_batch_label_timer = None + self.sleep_after_batch_label = False + + # Counter Zone + self.zone_counter = os.getenv("ZONE_COUNTER", "zone_counter") + # ------------------------------------------------------------------ # + # 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() + + """ START - Carry last 50 previous counted_ids into next batch """ + counted_ids = [] + if self.previous_state is not None: + try: + counted_ids = self.previous_state["counted_event_ids"][-50:] + except: + counted_ids = [] + + # Print + #self.logger.info(counted_ids) + + self.current_state = { + "counting_date": counting_date, + "batch_number": batch_number, + "count": 0, + "start_time": now, + "last_detection_time": now, + "counted_event_ids": counted_ids, + } + """ END - Carry last 50 previous counted_ids into next batch """ + + """ + 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) in zone %s | batch #%s total: %s", + self.object_label, + event_id, + self.zone_counter, + 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() + + # Sleep Blocking + #self.logger.info("Batch Label detected. Sleep for %s seconds.", self.sleep_after_batch_label_detected) + #time.sleep(self.sleep_after_batch_label_detected) + + # Sleep Non Blocking + #self._sleep_after_batch_label() + + should_reset_timer = True + + """ + Comment this to remove reset timer + """ + if should_reset_timer: + self._reset_batch_timer() + + def _sleep_after_batch_label(self): + if not self.sleep_after_batch_label_timer: + self.sleep_after_batch_label = True + self.sleep_after_batch_label_timer = threading.Timer(self.sleep_after_batch_label_timeout, self._on_sleep_after_batch_label_timeout) + self.sleep_after_batch_label_timer.daemon = True + self.sleep_after_batch_label_timer.start() + self.logger.info("Sleep (non blocking) after Batch Label for %ss. self.sleep_after_batch_label = %s", self.sleep_after_batch_label_timeout, self.sleep_after_batch_label) + + def _on_sleep_after_batch_label_timeout(self): + self.sleep_after_batch_label_timer = None + self.sleep_after_batch_label = False + self.logger.info("Sleep (non blocking) after Batch Label is done. self.sleep_after_batch_label = %s", self.sleep_after_batch_label) + + 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 + + self.previous_state = self.current_state + 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): + + #self.logger.info(msg) + # Sleep Non Blocking after batch label detected + #if self.sleep_after_batch_label: + # return + + try: + payload = json.loads(msg.payload.decode("utf-8")) + after = payload.get("after", {}) + + + """ Start - 20260530 for ignore stationary object """ + stationary = after.get("stationary") + if stationary: + return + """ End - 20260530 for ignore stationary object """ + + """ 20260514 For Telenan """ + camera = after.get("camera") + if camera != self.camera_name: + return + + label = after.get("label") + #if after.get("label") != self.object_label: + if label != self.object_label: + if label == self.batch_label and self.ignore_batch_label: + return + + event_id = after.get("id") + if not event_id: + return + + zones = after.get("entered_zones") + if self.zone_counter not in zones: + return + + self.process_detection(event_id, label) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/counter_service.py.20260604 b/counter_service.py.20260604 new file mode 100644 index 0000000..8790e5e --- /dev/null +++ b/counter_service.py.20260604 @@ -0,0 +1,526 @@ +#!/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 = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 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", {}) + + + """ Start - 20260530 for ignore stationary object """ + stationary = after.get("stationary") + if stationary: + return + """ End - 20260530 for ignore stationary object """ + + """ 20260514 For Telenan """ + label = after.get("label") + camera = after.get("camera") + + if camera != self.camera_name: + return + + #if after.get("label") != self.object_label: + if label != self.object_label: + if label == self.batch_label and self.ignore_batch_label: + return + + #if label != self.batch_label and self.ignore_batch_label: + # return + + event_id = after.get("id") + if not event_id: + return + + self.process_detection(event_id, label) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/counter_service.py.20260609 b/counter_service.py.20260609 new file mode 100644 index 0000000..93fcc70 --- /dev/null +++ b/counter_service.py.20260609 @@ -0,0 +1,552 @@ +#!/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 = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 10)) + # Sleep none blocking + self.sleep_after_batch_label_timeout = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 10)) + self.sleep_after_batch_label_timer = None + self.sleep_after_batch_label = False + # ------------------------------------------------------------------ # + # 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() + + # Sleep Blocking + #self.logger.info("Batch Label detected. Sleep for %s seconds.", self.sleep_after_batch_label_detected) + #time.sleep(self.sleep_after_batch_label_detected) + + # Sleep Non Blocking + self._sleep_after_batch_label() + + should_reset_timer = True + + """ + Comment this to remove reset timer + """ + if should_reset_timer: + self._reset_batch_timer() + + def _sleep_after_batch_label(self): + if not self.sleep_after_batch_label_timer: + self.sleep_after_batch_label = True + self.sleep_after_batch_label_timer = threading.Timer(self.sleep_after_batch_label_timeout, self._on_sleep_after_batch_label_timeout) + self.sleep_after_batch_label_timer.daemon = True + self.sleep_after_batch_label_timer.start() + self.logger.info("Sleep (non blocking) after Batch Label for %ss. self.sleep_after_batch_label = %s", self.sleep_after_batch_label_timeout, self.sleep_after_batch_label) + + def _on_sleep_after_batch_label_timeout(self): + self.sleep_after_batch_label_timer = None + self.sleep_after_batch_label = False + self.logger.info("Sleep (non blocking) after Batch Label is done. self.sleep_after_batch_label = %s", self.sleep_after_batch_label) + + 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): + + # Sleep Non Blocking after batch label detected + if self.sleep_after_batch_label: + return + + try: + payload = json.loads(msg.payload.decode("utf-8")) + after = payload.get("after", {}) + + + """ Start - 20260530 for ignore stationary object """ + stationary = after.get("stationary") + if stationary: + return + """ End - 20260530 for ignore stationary object """ + + """ 20260514 For Telenan """ + label = after.get("label") + camera = after.get("camera") + + if camera != self.camera_name: + return + + #if after.get("label") != self.object_label: + if label != self.object_label: + if label == self.batch_label and self.ignore_batch_label: + return + + #if label != self.batch_label and self.ignore_batch_label: + # return + + event_id = after.get("id") + if not event_id: + return + + self.process_detection(event_id, label) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/counter_service.py.20260612 b/counter_service.py.20260612 new file mode 100644 index 0000000..efaa55f --- /dev/null +++ b/counter_service.py.20260612 @@ -0,0 +1,580 @@ +#!/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() + + # Previous state + self.previous_state = self.current_state + + # Telenan Batch Label + self.ignore_batch_label = False + self.ignore_batch_label_timer = None + self.sleep_after_batch_label_detected = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 10)) + # Sleep none blocking + self.sleep_after_batch_label_timeout = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 10)) + self.sleep_after_batch_label_timer = None + self.sleep_after_batch_label = False + # ------------------------------------------------------------------ # + # 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() + + """ START - Carry last 50 previous counted_ids into next batch """ + counted_ids = [] + if self.previous_state is not None: + try: + counted_ids = self.previous_state["counted_event_ids"][-50:] + except: + counted_ids = [] + + # Print + #self.logger.info(counted_ids) + + self.current_state = { + "counting_date": counting_date, + "batch_number": batch_number, + "count": 0, + "start_time": now, + "last_detection_time": now, + "counted_event_ids": counted_ids, + } + """ END - Carry last 50 previous counted_ids into next batch """ + + """ + 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() + + # Sleep Blocking + #self.logger.info("Batch Label detected. Sleep for %s seconds.", self.sleep_after_batch_label_detected) + #time.sleep(self.sleep_after_batch_label_detected) + + # Sleep Non Blocking + #self._sleep_after_batch_label() + + should_reset_timer = True + + """ + Comment this to remove reset timer + """ + if should_reset_timer: + self._reset_batch_timer() + + def _sleep_after_batch_label(self): + if not self.sleep_after_batch_label_timer: + self.sleep_after_batch_label = True + self.sleep_after_batch_label_timer = threading.Timer(self.sleep_after_batch_label_timeout, self._on_sleep_after_batch_label_timeout) + self.sleep_after_batch_label_timer.daemon = True + self.sleep_after_batch_label_timer.start() + self.logger.info("Sleep (non blocking) after Batch Label for %ss. self.sleep_after_batch_label = %s", self.sleep_after_batch_label_timeout, self.sleep_after_batch_label) + + def _on_sleep_after_batch_label_timeout(self): + self.sleep_after_batch_label_timer = None + self.sleep_after_batch_label = False + self.logger.info("Sleep (non blocking) after Batch Label is done. self.sleep_after_batch_label = %s", self.sleep_after_batch_label) + + 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 + + self.previous_state = self.current_state + 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): + + # Sleep Non Blocking after batch label detected + if self.sleep_after_batch_label: + return + + try: + payload = json.loads(msg.payload.decode("utf-8")) + after = payload.get("after", {}) + + + """ Start - 20260530 for ignore stationary object """ + stationary = after.get("stationary") + if stationary: + return + """ End - 20260530 for ignore stationary object """ + + """ 20260514 For Telenan """ + label = after.get("label") + camera = after.get("camera") + + if camera != self.camera_name: + return + + #if after.get("label") != self.object_label: + if label != self.object_label: + if label == self.batch_label and self.ignore_batch_label: + return + + #if label != self.batch_label and self.ignore_batch_label: + # return + + event_id = after.get("id") + if not event_id: + return + + self.process_detection(event_id, label) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/counter_service.py.with-line b/counter_service.py.with-line new file mode 100644 index 0000000..df0c85a --- /dev/null +++ b/counter_service.py.with-line @@ -0,0 +1,588 @@ +#!/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() + + # Previous state + self.previous_state = self.current_state + + # Telenan Batch Label + self.ignore_batch_label = False + self.ignore_batch_label_timer = None + self.sleep_after_batch_label_detected = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 10)) + # Sleep none blocking + self.sleep_after_batch_label_timeout = int(os.getenv("SLEEP_AFTER_BATCH_LABEL", 10)) + self.sleep_after_batch_label_timer = None + self.sleep_after_batch_label = False + + # Counter Zone + self.zone_counter = os.getenv("ZONE_COUNTER", "zone_counter") + # ------------------------------------------------------------------ # + # 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() + + """ START - Carry last 50 previous counted_ids into next batch """ + counted_ids = [] + if self.previous_state is not None: + try: + counted_ids = self.previous_state["counted_event_ids"][-50:] + except: + counted_ids = [] + + # Print + #self.logger.info(counted_ids) + + self.current_state = { + "counting_date": counting_date, + "batch_number": batch_number, + "count": 0, + "start_time": now, + "last_detection_time": now, + "counted_event_ids": counted_ids, + } + """ END - Carry last 50 previous counted_ids into next batch """ + + """ + 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() + + # Sleep Blocking + #self.logger.info("Batch Label detected. Sleep for %s seconds.", self.sleep_after_batch_label_detected) + #time.sleep(self.sleep_after_batch_label_detected) + + # Sleep Non Blocking + #self._sleep_after_batch_label() + + should_reset_timer = True + + """ + Comment this to remove reset timer + """ + if should_reset_timer: + self._reset_batch_timer() + + def _sleep_after_batch_label(self): + if not self.sleep_after_batch_label_timer: + self.sleep_after_batch_label = True + self.sleep_after_batch_label_timer = threading.Timer(self.sleep_after_batch_label_timeout, self._on_sleep_after_batch_label_timeout) + self.sleep_after_batch_label_timer.daemon = True + self.sleep_after_batch_label_timer.start() + self.logger.info("Sleep (non blocking) after Batch Label for %ss. self.sleep_after_batch_label = %s", self.sleep_after_batch_label_timeout, self.sleep_after_batch_label) + + def _on_sleep_after_batch_label_timeout(self): + self.sleep_after_batch_label_timer = None + self.sleep_after_batch_label = False + self.logger.info("Sleep (non blocking) after Batch Label is done. self.sleep_after_batch_label = %s", self.sleep_after_batch_label) + + 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 + + self.previous_state = self.current_state + 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): + + #self.logger.info(msg) + # Sleep Non Blocking after batch label detected + if self.sleep_after_batch_label: + return + + try: + payload = json.loads(msg.payload.decode("utf-8")) + after = payload.get("after", {}) + + + """ Start - 20260530 for ignore stationary object """ + stationary = after.get("stationary") + if stationary: + return + """ End - 20260530 for ignore stationary object """ + + """ 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 + + zones = after.get("entered_zones") + if self.zone_counter not in zones: + return + + self.process_detection(event_id, label) + + except json.JSONDecodeError: + self.logger.warning("Received non-JSON payload on %s", msg.topic) + except Exception as exc: + self.logger.exception("Error handling MQTT message: %s", exc) + + # ------------------------------------------------------------------ # + # Run / Shutdown + # ------------------------------------------------------------------ # + def run(self): + # Graceful shutdown on SIGINT / SIGTERM + signal.signal(signal.SIGINT, lambda _s, _f: self.shutdown()) + signal.signal(signal.SIGTERM, lambda _s, _f: self.shutdown()) + + # Support both paho-mqtt v1 and v2 + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + except (AttributeError, TypeError): + self.client = mqtt.Client() + + if self.mqtt_user and self.mqtt_pass: + self.client.username_pw_set(self.mqtt_user, self.mqtt_pass) + + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + + # Start background cutoff watcher + watcher = threading.Thread(target=self.cutoff_watcher, daemon=True) + watcher.start() + + try: + self.client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.client.loop_forever() + except Exception as exc: + self.logger.error("MQTT loop error: %s", exc) + finally: + self.shutdown() + + def shutdown(self): + if self.shutdown_event.is_set(): + return + self.logger.info("Shutting down...") + self.shutdown_event.set() + try: + self.client.disconnect() + except Exception: + pass + self.end_batch() + self.db.close() + self.logger.info("Shutdown complete") + + +if __name__ == "__main__": + service = FrigateCounterService() + service.run() diff --git a/frigate_counter.db b/frigate_counter.db index 0f5b5f36d592c31c2644600df1595558e43c10a7..3ee92712a2a6e4358211c0962394e1f42dff9abd 100644 GIT binary patch delta 81784 zcmZ_12b`Q&b@#8`of+*)wzQgA8)Mq8Wxy0K(~AgTQ*DZEf#{NV6d<7|U}FgQf@BDI z%F+gyPy#jqgQ*q<45p{|gc1@U1UrEwl)Ql?@zut@S3GI^ z8T|5z+Yjg8$8X=0e~)Z0^Y3HF&*I<1<45xErQ`eX?!X=a47qsrYe3#u{+5MW`KUexm>4wr%ORL4-DZaFLzruG5Z!KKH zB>wwS`J(M7EbrQA9b8&y%;s^jaO|A>F5Gu|_nmpISJ$iEL)F%yYUiTu`{e$bS)ZJ| zbp77lEAF%N_qE=7wRfmq*T3Ht{r&XhJ(kw*yU=;A|3P=XRy$O$9;&r38ebg!prZh9 zenDpr(2>7yRu47n7mYWfzn`8udtu|=5532E^S^IOhZ?OzjqXLG=R|)$Ir*OT`z(z9 zq&@%p#(J%OsM$W$s9rRx#(#hPQT~4Nq5IGOzAk@Xt1{+`hF8UZ|C4vG-+SST=P<(A zMy=_1n}_P%i-wQM|NZp97Z%RB&v~1^`#S%%-l10eqT#{(pR=2E{y%c>ozGWw{%g%c zt>#68mqmZC^M9{}`+j#0Q0>YBH#&z})r$s4azB{7>ym@_x!Z^DIsbZF{+PvT^7VV< z{`mZAOMCOMcW%!AzO^&?)r+o8^hOrz%*)T0cUyee#! z)!rx<9}~8+sBn+fgSl4i@&3Yp2F)zcIpxCFf;QGc*~sb*xn>29~Y^u)sQ z;#K!H*gpBQ8xO7)-k57# z_`tzR;SFJ&qOTOXux zm}`8)2M?ADgRt@Z>h#oqEN)Z^*XG-slj~o~XkHVwPY<8Fw7yn&bwt>!!zur2M3|oX zw9NUdB7$6UtA$sF?UR505S_m!*SPUv=KN&XD06=JRV?#~s9kRIwZbc+_Ja0t|I0(d zqCSS zZm&}fDXqq#T79$I?KImhPg$=v4k^VA&P<_5ull98`5y>Kt<6oo*={tdjb2CrzE%ak zQEi#73W!qN;EWW=^wjqk7|QRNZ*lpzxDC6Te5%?t9iHJc$acNi>D2vi1cjc}TW+wH zL=jHYU%u7<;)sYC{D=vw*SghiNCCgu*zDA3ZhFdkwJtqtHr$|+LJ_ugti0rZaD=P3 z#hmpvJJnXLXQpKaUuD8-^>(l3e`OXZx+6KGY>l$o9pjXwl z8>~hMJKVXy{BIV8c}3!DGE;2dxolT$9uY}a2D#bmw7NCF9F296S%u;T`;#b#Kfbup ze{%@ZXv!|FRXg2I0HW}^P}N$??<_zNU0W=n8|+JgtW9dKT1VOxP)Hj>mPPa9e zf@LI4S=m+#_-d=;XHr3w;s&RuKq?~R9No4cd`AS*&03>Zt^58eh)B1c&88saK$PMJ zr$r!gpB>v<3K8lpnf6+()~Wkx2SKrG4X~>PXIPmI7#du;h%=6_93RV`pH+6}gq|BjAYhr?A-d=4k;RWlSOtRp>ZuquTVipov>;-f+o zI75@QueN(t)8iRFYhUH;m@5~Qw)9Naekp~55Vmo&5MsdP}tJhW>ChU6gSuvA;<-?@euCw(z`YdGcy#dz>DZm3#YqA`knX%-I zP$=%&m#07q%Gmm@5sE-w& z;{W={>`tp~Hk;LE$9zX9VAr{Zo2^zSIO`&kL2=h!9U~x~ZT`v+b1`fGUuDv`CwND)eLgH{AGIq**h`^8J=om7Md;%N=pwB9zdX@)JwaFuT~3(fymB(T)&-0I}o!0QHmQhQXu;#KRN$y z{gvGo1(6v}#YX5L=la91Lt)D@lzO*~JU{=94n-+$P*0(VGTh&HxA_nFl(0zIQ`Jt5 zvlmiY2ypPQPM1q~PHD<5tQ0q>r9iT~t914J7oezg0}2cV0n$=fUAewC*=h`PP64Hx zQQV-KLfI>Jqoe)+Do9f#&dn+!OVwYHJMhTibr!`Oen42pCK9?E+%E!|eDoiY=f6Jh zl72_-?apSaBbq@-!AgtB*R3Px2gB%y{Z=IH%?Lp@7q)}`@^8&Q)zljhr9tGYCi>N! zlEGu1Mu+6$Y6L674J;HlI6nn4J^bB;^{o~23qcXFxyR;GJ#mL0X82qoHC6@EtZ!Ls3U>nfPrc1O zU#}a*mVt16u}9k?S)z6Oe(Cik6gRkgVj#`w$-hS~TCsW@n?y_<)y*CXitXc>e zz|FfqM4`vrubnnmmYJ6szOD+#)bI%Le$cZ9=lz@rW%AJ*kc{jWW*lr`jzX{AY?$i` z0~=x-vsIVX3b54~BNR8-h!JoChMZAOEFhiDUW2pdIin(gjdiP6YjH~axCKEeZm=F9 zh%_vQ_r4L0_!g!HncK}OXR#NcC_cIg;)xZFSP!ldY%PrAZb=lZlIzD$`;qULpj0^} zUB1<=cMWTr;frqHY6+X4vjRb(XTs(0nnF1@Iwqp}GQCY)RLBXzqANUxI)(yo3_<9a z80zkl0-4^9HTYQRl>fyMG1;iO1Eo-O(|L!F2tGGIh7k19@R%@mp}4`FBNQ<$A2T8` zO9FR7*Fq^|J+S&21jS~Atxlt3ZMW;91F>1K)ZHmYz)pS4-0UDAyrXH<{s&@57FoH2 zbigV1LY59g)*GITeb5e2K$L>g@PL&Qi@O$1EG+CgIXS+1|2w$B;}f{;>8byeUG{-F zaBIfc`8>i^%ZQ^&k;_B$M`U5YLam)^rF-?@2q@vTBF@C)#ze2*5st?mKzB46>JB!> z&o(wBqXsJi*G-Kh`MAV5TAO+#-)M#*f(PPrkFDIQ8|zt1?-cedRvhkNvkkmg*$p0> z0>Qol58M5{5CuCjJfziT)B5eKDBw4nY{Ra!?`(8MQHmQJPN7V$A96jO>3^VX9Y)D~ zv2BC-l8Xyd6%tvM-JSzciW^*(0?966V`QPjuojhBnEGZT7z(q+dU0;5C@%pDVv$hX z;L;?DT*Ajn*M?((yVB!cv)whutsQ(!wyj#TX)a+Vj2j0x2xsS#2xW5Oy9bY#-eeK< zEYsU|wS$?%QY79)7QQQ5o82k;xXBZhPk;Q6Z z5c9v$y9*}6-Sy}c$n^HlVj^7pmj8u(OVkHp#~s!_q-2byUc+GISr9{Egc+lGR03ss z>b#|m{_cl`pJJzA6>6Z(g)0I)?3xupsW4iFf+)oe9+?0kP=IUr*y1sZg5)JV+iZwg z$g?vhOtd3G!N874?til`_qI^n;Nl2n@`~>r>@VHu|60C-K7%+xPZ3T+3fQ8>w?!WJ z6hTmm8(b72h-A+lywQKQ|AByHr@B+EwQFAe)M1DT4ZlqTA5;!RDQ@tH6v#QUxi0st zpHHFdAmBAcz_V5d1pyxI7lFUtFne1@q7*lHcmia4)4i59jvJMcPrf(U5w8TSJAqm4)I44~ak~r~V#!_*T2> z44Ub}9w79($xP#e7nL^YMa<-t-6B& z2kD)$G{9F`#azCeQK7iO10$5lP5*H4`0^V<1f&j@mm|%JgcPtb-f(srb;HlZudEa| zctC`}!k;D1*sVVZKgDDny(t5tEt@2rY~D8&uBDUix!<7Mmpug-sf;<#v1p=PJc6a)#f2^ zpUo)>f?!w25kRE%;#Io}7Zgrk`sUL6rmH6wA2)r#>cXB|o_AvL%*CRFNtd%Asqcj` zLt@=qEqox?h(WGWxG`*0c8CqFTzG$;pk}X1;eGk2sw@_^ zEQId1s9g>HtA+1`gvn)pOK8{MgpKHu2mX11(5`Ri+uLFRuN3||YFDFmrSPqoaPl=0 zkoL`xFuC;a80|O0Mjfq$(EU}^o`ukr|1u&>53}Z#!q;PhD5a}~zX%sy^vJcs*Ybbe z+RSG2s}VufwMyYD5kWMpO5w{KS%3Z{BB+30 zF8pzxpfY)-@JIP}6{9!y|6$lJf^Vhp2VtX}Ah8#%6@EV=oTL3Y@OycJ%7m4|@8;WE z)60%RwChTg#uDUr%kd zB4UKYsEfJP=s{3WHzbl_vx!x?w zBncxF={UqDfqenPvu{JO1HL#VT; z6F|h}gGlHG&rg6%Z~8vVZXCAyDN%SNT#nd@90>5Z&>3UTXh;g86gPNY0;E07beEy; zmTFsB#A=t|6SGfcDBw$E1KOD}Bq)kf+~Bzp%H*~m96Y|~RUrb(I_|DI@g+ne&!$+=zg6)0N(=>CCPTsh zTPG01uDj@&oNKWt;;J|Js_O~G4W5}mncnuJg^gpSciEw+1qJhG9W6T?idqyUmbESX zX8xO&2>e*|MA!Sp2xRh|A7cLWRs@Ej<`Y<>csQ`%n`Os4fH_U#Olm|<%_)kY6gT*V z2!Ums8G(DhfYqU~_p!3Plx{t5BZl9>fUxC-;s#GofoP=E(qDuitgl=p5}=U-VOAulf|LYn zj}Z{1xWUsR5K#vDEBCetdZ@&y(QY-2DyPE`)(qFG_o{?S?W~m!Ln&_X^AUo|+7c=C z=J^l!6kAseINW;}p8et~MNSX*2q8IU_u^AggHxTkI-UaAr>Fh25JW7d5)Reu7(;3X zuj)Pama4gS6@=gkQSbXH5Dk=C{+R`VYrs1=B3QwUhY%3)w5YeERc{*`0w5BT1H}!t zQXnew94n1N6buje0`O*XU4;}>UyOaNCRdD~Nl@e(gW?94M<}9_?=7=kqAPg z+cOR|8cMhW2h`eP1VK(a7MEiY!c4R4FMn|kg2N137GD#$Y_DzNGZaMpl!W+KMHKSC z(I{e}xWUl`h^mSG((C*W1VloZx)M}oL(~;s!e)_P*t+40uup{I22V|asFc_@GM#=) zgf}9U>K$Ce5kBG;$Asnb6pdjLf3+JtC5a*m?6KvK`(J1r5j#bur5dhXuva*VUE+(3D54-@x#1pRMe-m@af2tOKnfi9dp-$!j(&qZ3QugeFl(E; zXcoSNmqRQ@MkdxF&?AYNctYyys_b7Jm~X{A&bGoX)xeW&&3IzsA|6N_#Lb>z{Gf2# z3gs=YU%ckbVJgF^s~Juvs;sM`h)zkPLBO!zSxOPBC_B5`wPCVkFXMv|iW|lmPSXRA zSs<49P4o5856NLiq>_M0FV1D)tQ0vGtdmhy6as@c{=i|J;>2OU{XTf=O5PgHr^FtK zuY;?-Nq9yMUV;*^Mz^XaBuGItT)_=fSx()|h8xb7%0ZlVoQ_tM&hS+Zs==C@o@at0 zz7)B2hp8+lP{fA2@;2LJVWbkoMD`5%Ga*IfusV^wT>^tVMNpLDhN(2CuBJI1{ks2w z+Hf(i3M(fVD_9k7L^Tu(k2id?Bj#wOxM7^;G+n)90V(^bAq0IQLb={*uv0?{TNMw1 z>y%&8t6{JIah3PKqFJoCo@-cNz$p@FqP%hl`N;N zr}@8EAxL8B2u;BmAUp{3>-Egds}!_A z2~X}yxUKJ*3PD0~!#LAPP*eyiKGOd}P=xt%rb#g~c9b1_6zW_;HI5ySV`6!u_)LZ31}}|7$LZ=_VZX~h!3_f)!5$oV;yqfZXC6W+qW?=oJp9D3 zJLVwdKcTq6uS5_C*jaS)^3C&s@GVBpNyPsv+t+++hA(*=O>))^MNsIOOSA(bklPJJgSP-NdB<&ZcP|no=gNyA@;EilA)OJ*JBmeHe zBR3KgU?ZaxgpNs6?^P*~EIN7lw?hdwY9Jd%n8ox;@X^)7$?ix4izu!oO(67??D%Y-`24<^2|2t;V(gu3`K1 zwnr`;Tr1usY~?(jBe7BCJ4fy6+*m8#DI#bv)q%6~?bDNIEN-k8?-;gEzw@YtgO%c0 zVXIy-;)W~UAtGR0-zc9MHES$SrFce65EFK#xE2w#4o}+6 zdPt?XnjoM$tQGf1gf*Ga{rkdp70}m;r-!Y~Ls|a%z-jq*m4Yk9z4>+(UdzQj`F52z zSBooQyNZaF;&Rvusz`O^;!gdd`B_mxY^_*|{$6FLaxrRGai>x&L_}6@Un;{S! z@GoJzI3p{CpM;I_^{mLyk0U}B9PrOkdlnq{h{4gS9 z!2v&r+GoK5-%k-3?LUU?;)|&i{vm9XJt#?`i^Y4Bm2uDMJug^%>X}1sqo`t1PZZ{U z@d0x-(GyKibU@r3ls7RVlTtA1W2Ej#@ewb4Qua`d`WUH)shr`S=2#ogt$s@GRdjD8 z5EJFS1CKbxy^U9O-Z2Fc9kA(!sg&W~J`DtZ;rs`DYR95SvLI@WrRbe5zVt@BVN_-X zQJb0@#wo*0Yvwud#&AF+zZp>yMNFzCSrDuUOc!y1l6YrrN(ebhal=&BFl$HgvSX$S zkfP8n;$)FQtYEf-kMh)&=x{H_GZc}b@l(2CoHjgt#lx{CF8}$wyIs-GBpnIAP>=uj zTG$Ra&UBQsCQj%beO4jpRs#b=I%oJvSQD2Y?SCL35)p}Ek|aVq@d}@L*9e+6>Hr|3 z0jr%al{2hGNc+W~4?#qILP;Xo)`T1D7$sK`$(O=6Mv7MurMO|7GQ4w~`pY+16xKs+ zKG=5wvJ{N^*b$HmYuEt&Oe%^}+%QfX78JQC`zw3xx0qh_ev!3gQ>ht?;-l==r4W%< zZ9qZa6s5ooQ>nw*^>nOseTX79FA15)Hfee+DB=eu=cJ3?oU3izr(;RiQUshYD;t{zI}aF`M&+>3bQqwfgkOKiE4V<6tu zkRnqrkpa}8$zdqP4dc9F6*l^(dq;Fv6<30wh{WlVVPzr2VgVz>l+uNF+I1HTuTb1D zP8{BmT>2|s2?j+|%$X9l7DF)u5yf5eCac(MjKc^20cS)gZkS3QMt+p!VWVv-iVU5a za;=8dTr+$u9T>HrE)?OipMaRE%52CxZB%JJ4DUE5{et93VU`% z6X>sax{&bu)CzN=L$F5EL>1g*ePOS z!Y*!SUQ@l0CWr!~=9R_QR4<{pVXCkv26#*-7cbpvP;j~+;2|kCYeDo$hOc~$3F}z= zS*ll~h8ltE`?2wOYSpY5*sJG2J&_q;M$A7>3v%#63L@$3sy92lH#1+$RiDUDu2lAddb6ZCzQNI%!fGia=PJPTu=c7 z;BYo#LeVRXGY$yxNJDYMI5~NG5{S!-e-(~`KqVU zS@0aPC#ml;D-?mE0Im@-e0g^_Or;HjLYB<&6(29jw^U{jZAWG69KQNY&|N5?<|%T| zOHviwE0s6g%kr$>?0+C2EIlASvP6wwlEK4saJ9lCjeN;a5LtxchH=`kNcLiE>6hLb zqOdFo#8o5{6LPC4h#B~pNG1oy|3*-h;)bczVWdbzmaXD({|gn2=ob9oWO={+6atQD%{V}MHlm!70MF$m0hJBn z!2NGTWKoJ6#<|0?zf@!~&QQJu_hM6$Nl-fdVZPWZLUF@X`Y=k$&D6W_t|SFv_7SnD4nx(P z5Cp9qk%i#pn(;0wh*I1zl|I~?yx@Mw_Vzvzp@l`^(jqLu_^UE_BqqX+Tlo3Sb*4g# zP~0$;Jq-U+q2QgXq{0}lv73mL>ln^zE}xM3=RnEi#&vSqx8ScN348_p{JZgO}-d@%?Llh%^C z^Ft&LFirV%g(t32($mE-VeT8vClHDorZR}9 z2evr$E8eHCpF&oL6L+O1uH`HOj};9kA{G`CBE)`p+rUxWb zF91nZE$wNzUVp`&I=;p9BY<&n*l18ikw`P_x9}Dd9-$~oal=&EQa0E-)(z?&6-^)6L5k?{au=>90!f zExoYxpwel@ZxnARUQxWDczWTRh4&S%EL>RFxAd*0_bA_3;7yo+k#>J~n>ztN3 z&!4lmySKRKD|_Cx=edgySzKNC_QD4iUcB(oJ%?_2$7PG}TbO+Nw$pihrAFinDXOc* zUk*Ja(*xV$6?;b5%Ac!Orug)zJ@d*GpOzq~ulVO9Lgp(z9=14`(G!ceQMY$qm}1VsDPY%fDE6cJfA`V zz&t^n#Rs00Z=W7`-4X!;PYm1Nc)x?I#V3Sq(@P&JU3q-iD(fV+?@IAVOpub+6>J#! zzUcEQh&=iBf;#CBhwZA~trjoKwTeb|pm=G{ld4KorTCcek44N9y{A%qbb^4YvPKp3 zurpP(3#>flM@H>4@9V`eL1g$c2Ep8yRP0_WJ|f?aqzuA=hv(Z>xLqqgEZ>eiDm{8= z)IJLec}PT1Lsj{~QM(9gmEwhQJF-=!ctO;jIbe$qiU<-GQhXpqAL>+%+mw^L#L30m zEJxy(a(~WF5m~ofe14uVJ#{Va>EiS9?N}&o=1e>{Y@gmfkdyeFuoc+LjtK0t^X*mX zW%*fAvr0y*#b<_u>Ge-vVDtZCu66wxviW}@Y*q0c@v4j)Hoxd15tWNS7qyEJvsS!+ zz8$$k7WYupp0TxF)IMWt-2_3|S|=hXTdTCA_KF}^T2Z?O!IYa(d&bxrar=fawtCzy zbuEgus9oK@o@u7(57WD236Ln0wbiBYb|xu99&VLUEXp}1ip_jz`SZLORhq99Nq zgpm~?s=Yx`d@Q__yb)z}PEq5!CUp)IxzE{w>zC%5YBm%kV~PK5lNdagHZn>EPi!A) z4k3tA+%S>*JUhKx-mN0vLM37FQVfom$GM$NCt*+@7ayKJW1&W5@_Z{KAY!S!8c1 z3}K0}4VE0EiFC*fhY_M3Fe4HHSv8K>*R zN9P+v&CLjLm3Q|DWZV30dITA_FOp>Ulz6f0QDG}`D6xbsE83k=m zv}4R7K8-g6QHmQTa-L;V!9R~J&-E?7CF&_k0_G2S_-n2NTYU5|OvHZps-8;E+%Qgh zR*r6pzGe_uv$0E%AZXr%2*GjJ1X~KPy>!bBJSNI-j7MU7^2r)2L;1HZr z@^h_G<_a^0bjuCnlxLNDO*W@|2-_%zTNG1qC7T^;!EN+^27!V?L9<~Z;W-nc`#yC$ z1Hn9s$&0!J#@8FbQ~4k~eJq7^%njp&XAu}xG4+=2>{}8gBQJa5^E1<~dG;B8lLsOK zb|pp^dgg|Sq-R0VEHcab1OG26B!;wwRQbp08xnZ34teVHx@|SFPvBqRq2+`)@bj`uRHp(0j zY~IXxmq>cf)R;cfy=N)PfP{q;ns1Da*$6Xuc|X7;DJqCk+%V307QTy4WE}JIDYiVO z8cD3iN;9AM3`$UBi3H67fd zX}(tg!P=!AbHha1Gav{xCK4wbiS&v@6#@(_3zRWt@I0s$+U@{RiW|mh&#L;F@F;;$ z(*m9|g4RvyP)5O|Co%i&AfTObVCfTz8zyp|8OQZ6-Pn4Goh$4eV!Go>m)w$&g56)y zd9)#zD*%MzhEI;|Dx5^oNsq7W3%`ZnBsS&-rDQCRXN(y>4+z+YrgS7qal=H`bFI2p z40o5meg~x5o5jURSx4%V`*I)Ibj{%F_?+gPQiLB?EXv^muy;q1Kb}Q0U zs9s_Hq8=SKK)e9K~h zlJ^uuCbX0teCjw;{KZpbAlfrGOqB7KjYTb}i=OR&ARse0b(6Pd=Ilg3#?XN(eg&$a38BjVL|-@XG>t4UQyPCGVENvHSamNJU?AhF@&1N7!%?q0f} z6gNtwJ=b8tIR5*^7g`hyulVEf0gBgjzJ~(*8jCg`XojLabE8DsbFHTN?iiGI_&h@q zzrKW4G7VV1o=?7t9IV}EgdY-CM_yXfRxXio%)#rFi}2tI zq1|$$MA|dsz=SR-oliuJ*Uw035L`|J1v}10RNP7lEWE3pTJ44q`VdKElKu9OG z0Yb_Qo)-;${vtb=GolnXiZh?5uXr^_ed%w*4~a~NBa0LOf9+)>oPo3F0>&uEP$Qy7 ziNt5dA+Mu(54n5_L5Lq5%sitIpwD#)TQn~n%Qz5(gyKetyk}0L2q{~|x7nd^ zhazf{InUi29ukjluigwrd*()Q-ZLi31FwUrEP0!g4u#Vz(Kk}6d+zwlhLXW|V2wWH zE(1}D8^)Q>a6`@i>KCsKha!e5E&O8wpEHyld?LvGG1UoEiW?@9pIH?uIv(>8D@awy z5gbPJQG{IN(=&Sf(^IcucbSD(2yu23;KSYO9herp#bo*H6PYbU zf9aMRCQ_f}+Lh4#(r?VWt0)+F#I4M`MS;G+5Ho!Ee|YVR%q2Z@LrHzk3q-Z-c~9I^ zJBLwGMCZk1L(o%T*8(NOCo0E;?q_3BiW|nc&#b&g^x@12KsYS2CWMT7p*@?h89Zve zk44Wwl;Vbo?B`ldy);LUS`d}3m@mS_yFQF31KEKmBFi%zT@|IcVVwLtz3EyOp5nA- z9PkwJsgucqIn#V-CfF%B!3>5Xk%{8CM=eQYKFcwYocG-?wkRn3C~GjU7Lh_`pQ>Ov z!>1UNKc);sVxj2S)k8^r&IR?=CO>)i`td>`#6WmNE0NcN?OBkh#Th#FHGTNFtO$3t z^vez7#Anp%ED&z~cp39fr;t`qtGigTeF9ZRf!!jw61ira&46$zD#Z;GbyX3zvp_h{ zAVHD10I>&>T-2~asK;kN$RjlKg%lL+nHwhRs)CZm?#~}ylpZs9fFw;H9m|Ni2A`Vx z<{Druth@Bg4PTfzjVKOeX+PPQm&#B?DiGTcT-fy3;}gD3ptv0iL2A;o|DUqwCzkFr zeeH?GJ1x|2KEC?L>ev?d*kt2*xT!vob68}Dcdhu+s6F$F{z}-cN2F4GN!Y5)pIu!p zzBuO?)s47Tyej|q>>a#69HE}Mdp{nvX9K%A zY@dGTv*Ky{SlD`gt**zlTs##G?E0gi-jx6Q+T`R%a65f8YS&!b@<*a}%`RRmemEj@ zbcQ|@wNI|U1n1-j!}jUw=cW4}2wQbs&K3vsjS*pT@;$V_KW^7?y)SNuqpTKh2-~Oo zzaX&h4O<2F_M;LH@ScdUsiXRhs2$-!miOHeLCc^QuaDaGZKz7|x|kp?_toONLc;X6 zp_Dv$XV^+lvyJ%cQM+!$a`7E`!t|zrxO?B8Zx?SEY2rw7!9JXRXCyQ9*0433Au6F4 ze=Q&L$r8vsBXBzEVaTpP@sG338 zJ{7%j4N)~%D_}F{^J}8^%=!H4xLuvkzZ$n|RL!fxc2!#{#aD){GFXkOxh85?<)BzjSuDOx%Ak)D`OXXl z_IKP@sz%Z$e^m@a5<-g6)8C^?5w)ATUdXgY60NP{?o)~zCGwpK+!ZD=`O|0ZrYxKw zaO+9};HG3<1XdV>ji1$~ z_rF;eAFTAujS}h3#KUP3oZt38kfBI40KPQ>ZUg%slo>oSq7il(3i_gS%#9N1&a$~K z!PEIw3nC(lm0~JjIv>4&D8-H9bZ5PM`u=%K`4Ap5#9&eUq>P>a zkWhBO5o^4Gq_!>&Sm~ARVKs-FpzMJI|xmi2rMsc#U z-u~7%tRG^Vpk+t}B_Fw(L1f_d7N^JSON@gPBHePMM5gmhlsFz3twhb>WJ#5|rYYi- zq0I0Job|;UYl@;2H| zW1^%Ailx|mNFXxs8alrT3t%9^g5?E=QJm_`%4fRrYkZ$11PJdXb{MIK>ys`lJZv6r z;D4h*OwutoN+dhWQ6Xt;i=a|7CrmEZ9!aTg(&vye!>0~O$h}Z5rDtxG$ab!&0BnOb zSa_n_xgXk6>k}VE2$I1|o~Vg)V!k+C(lIwmBskcXW$2iF2Lh z$V)i+@!j+E5$P^H;ufOzHf5uYQAa5mK2`py|LS`tC`xgoM7ndWc235=&h)>SrQ1+i zU5aEF4^)6JE=m6zjc1acxltnBnXzO^V^8wG5ENdWAdpQir_g`EfnzQOzY+NNB;H6V zZj{J(hKZ_FG=Ce82?Io~Q%S8LE79&R56=m*wsHZ{j=9lVwDNKV>l-)!5{`sDNq-R+ z%mZ{5UVInE)W_Px4y0plREa=T)9M%IMFHePa;ik&h1Uny>=8+iA-0dSae|u64#vlZ z!$Bx+RE{8Iduhs;iPBLN2}0yXA^G1}SS`LdlsvNsMJ&Sf%#9LNzL_mz@}9gK!(Ntw zo()Bv$|O1w@MAAW2ARQQlBMwQ{5J}sl+$GCwJ{Xxqo5b$$bmc)e9D1SQ%Xs zjc@vjPhgPUeYg2H>M~K}!;vu57E`4^Ay9BMsN00H3RwUAZ`QRLq8my6bN=eDOKRVi zxh@q%l9^$eBwqOht6Bh0$g9|X#dRbZyjsq!JSIC%I_5@+^k*@M zN$8}n*(x1o$+1aN0EB>CAI6qZX82%RkwABx^vsRo{O9S*KPjp8Z=H`^A;kF)3!pr` z5$G*AG15No9X=vQQ@Z6waq{!@6cES51wpD5S)af&9@8`%NXD0e<6g4Sx(cBbH%eqa z*Xmgz3Nv z_{4%kGDk;15BD!Ry7bG95}D7ex&}`c9~zDXr;r%?dDO$mLKdG()VRwS3F3(K%#9MM z&w@hmR^8<`l+(&>9%t=tn44wimK3$aE3y4zOD3SBbWMM4aB0X`ZSNbVT5mE)8fJ3u^0{A)s z#`E7Oh<4145;@N(cduUD*urfSfS{<66UzJLJs;vG+(zR}6&_Eh=V%I|9dn~Z&NHIR zE0=f{!AB+VDam@}9)+dwa!YX7*>a*@N}_Pnzjq|lCB9ZDZq$oz_vuGIEf>l5d>pkS za!(^Bu(L4cge$TGPQ4~x;_$x_24$sUU`I=JBy1nvh`TibE);#ElZ^&b8@@*Rblw zJ*gm4cZ)zLsXGx2We1+Pb(@+5h)TH>8X6_ioz)H54Rv=r5T&?LBLzZ%;ZH2CAN{6X zc`i|O9^Sr@ERNiC?ck#+d9JA_N^zsO-l}>fO|2|hT+WAFZ}L0|k1V9X(dj7A?XDRNjUp28D_*?p9qjrg z1S@nN%paz}V>ZSNol5^d8r2!PAu+Pm|nQPc;T6&R8Chz>mm17w&uUD z&t?xcf*s1n)Ini`AO}b~I#R!3J+hyb;zp^YuEuQkV7@cuNKxoNHFU5P*l;0>&r@h7 z-mNYplR%!{)g#I7ie?Y9^zmgM&!JzE*^>vnREkWZ*LdB)_iHwL)Jz+t(!1*S&x{C* z{ukoBA(lnL#H5CeKOFTV+)~`O1TM6zlxPZ2xDSNlMydR+x_XF~|1t!b33Bpei5GwX znZv_=Oza-h#W9eMxlt;=tFDzRFMnGIg0TRZm?tfW7kkxlx?sHNE{-F&C`(KNshXq}F1D z!uVn4MW$1K(h=EdRw2rX6#m zIJ-;FI^Ln>2ZbOoK>X);=&gA}$A~Hde2o+h8K<3oOS~so zim6)|sC422o8c3KZgzzFpoHQ^PmUz8=_NNK+wb$?#y}vdvQfKYjP3<09S|9Km5N{{ zm`?|y6gP^~yQU|BIKKQTJ69OTSa_0_=%}xJDVuH%PMvaNRR;q8Al-7KRB~5cBakjX z8G^9gB`6=3Wi%=UnZfgFoaF)nq7*lZv%9#fZu_j*AKz#}uwbCqNa-a?@C927IRsG% ze%8(1x?)%oiW|ilUK%T>6_CGZQL>AN=nr08_od@xzC6BEBlo{ir;YT?jgCaCE-306 z@&#n&Td)nzA_5m-3r0~;id=l)^U||jFZJ69#f=^pp-fMFoSS6%%f3_Uw4qbHMutvj zGKKa9Js`T;g0MiY7SkY)PTG zQK}MZedZcM@te!1a`}jf5c?pG+Te0$+bMp7Km;TA>sXZHMsX=rU3zM)Og?w@@{|vHxU_HaTZ`|H3r6wQ(rZsF{F}VBRBMQ} zs$Be8J~#^BzLZ}1=Y0F*`nM7<_g4b*x{D*uy5~zIt@1u4R8q3A+<=aKhTP=P!Y*x{%QvAEH zl~omKs$Bfre7g!j<>Kx6c9B^s#oO}DDrT$}zZ15rWUyBJo3Is74MIKe?R>l5zm?)& z=i3pFhs36SD{9wUZngN$kf0aSYVjMnR=G>osO6grIn3DqfxpbRE61)Bzn*VLTh~D7 zzlaH00P5F5g0iwo@vC7glc3yZt@xFQkOiQAIclE;p#FJ8&;Zo^e-^f@@2*n()38-m z9Cur}_$T>x^^jGHf1Ga@r`AUKkD}&T8KplA3F4@Gi@zNb)P=HE{H?GR*sP?}Z$|AJ zReRt|`F8OwREl5BH>*2fwfKdwT`l-)#m|SWfM)faJ{Pr%oxEK9Y`$Gh+vVbCqIR)H zuNH3&+tq-)TKsgbRcyYM;-|t^`Z8PCTk?d4V`41FC0!Z7qbVM=_ zDH%LbY1onH6hPF+M_|}EPA`^~uf3fx_KW8~P!M`0HibHNP_OP=ctT!{wOT>6V{V*E zFm5Q9-a2SOP%V+orJ6X-DKF$3c=lE(m@^XXm>Z`Oj2l|8;DJ}m8`QFY;1A*e6IZvX zPn=O^(D+-;o?{d^HKbE+oXRb3WRU^8{~;U(;rsGxR*M$|g7dS3-yokdP{+itAw6@W zRC2MPz`1-W3>^w*j-g;GYMa0SrDX8p*)=0>U1VpX>KzQVU`RGhuydn6Z|4qHACC4-0mw?dOHTuVCU zM!yp2`f}R2owt_1ZTl;wR3t@$V0`@DA%#!|?671t^A_Qpg54O$lu+C#PAygm^qk3M zSMHX=Aq)a{HkTD%JQ7F^&w#RnOjvNudR*lD+9x+kFM$no4o?6m_qy+xR#lRYxlt;o z81aF~fd2A7`CrIT#KMDRugME4<^mVW4t@jv?|&mGN^zr9(b7g%tzh|U{ukmfCAWz{ zHr@b28nTofe68%@Pbm9K;_K*{8>On2Hndv7*5d!zZ)pr42~jml&zJ}-7HkI}FBD>u z|BWW#OV8XWS+!K`G+Vp{ZPp&6kOanUHt|0CN>JFb03n1BUt5PQVM2izirA&0xKXNV zsTh4OUjCa9LvIuAMC!vEz9ASHI{d`Sl!~Dgyxp~F?SjR_cNnKqyqbHJ-TNE3sa%AF z54@edre*g^(ZCf`t$)6kkL^b3qsj@RTT zAHvJB5Mqd@8CjTHK97;1H<5&!ISi$^aVo{DnH`vwJI_%Xa`@zxG1Bg8L7JeBB*SNe zcMXTpktoHDQ#oGEth~qa&q5TgN0JL9TGGgI79R*pkz=VnbK^M4OIH5mEr{HnhwG=< zwW0>eiwb6WmBT1Jj{KT&K{FKb!_qM~PNjJ@3t8~NG#rZXUa3)o`^lg1IX)hWR)Ze+ z-w2BI3^gc~=GD}YkgagS=oCIo-fc5F z{ol(GXBkC#5I3|x{ETD=y(aZP4MiD@^y})eBzffnLT1^J4-7#>DkZiB4cI6<3X;K- zWm~h$B_K+1<5ZGYLrlEKm#;D)gndYtQFE!EXhOZwvW2k=%aL42bAvHk60omFK9%Fu zXk{z^B#VOA1i1n=h4WzUNkz%iB|8k#<8u1Wor_~p33VD5(RTX>?h1KS56+A%jyrFpR` zM-f-Q?#Hef&{%Og^3JFYmz~3F7ySXIGAqH|AS~f1! zstLRWL+(LEr%1WgEhJh)DQ=uf@|vt(j3)mVAqL$dTo(D=Jgg%tqEaIQGx6-avgUsy z7}76Je4OGX>n@cjjxYNF8otG%ljX#HxaD#QVYW=if5;uwl#BG%7@T=EkWkugS)6W6OA>6@w#BVibC|6-_O=98@m@ zVvgbjOTXOs%!JT7xo$|Mu>H2Tm~e2=BTDcps)=qf=x9PsBLOOg_REb^8D2<}(;4{C0*;*;BNB|uO(r9yGzREpQ+r2z#S!ckjK1nc-vb` zggHg8654ExQHr4yH?AaR3+bC@JdRUv!K^OVJld{QvL&80K-s}3mZD}{xS&W#6Fqa| zRFYTYJdHHnIwM3O>KP76vY2_`RZhwrUmgZC_!1(ZJ#*t!k{2h1?9u+pkq|}LI>J|r zd{=Yo1Z9SgU~cRmjD^jW316w2rto2j5!i2bq$Jf!=p!W?sW)egLOPZVAAJde@0o@> zNYC6jRn(MqA$@eK^aB42P2I)%#PtXlw)d04M>G#-i~s@YnH#5ynof>S*kNae7-F6v zajb)(!_2)$=L(iuV)Ol?iNn$_H;!wXX8dcBz}Y|dzYxd3j`<({VdN4)5qt?8kuZ7x zn_1>?qxqJb79TXdaCKpA`uGzIcfaK&H!bd2EMk&qu1SDXrJ46!G%MQPh}t#z
    & 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 diff --git a/templates/dashboard.html b/templates/dashboard.html index 4bed33d..652a052 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -1,796 +1,1045 @@ - - - 🐔 ZenAI APC Dashboard - - - - + * { margin: 0; padding: 0; box-sizing: border-box; } + + :root { + --bg-deep: #06080e; + --bg-surface: rgba(12, 16, 28, 0.9); + --border-glow: rgba(0, 240, 255, 0.12); + --accent: #00f0ff; + --accent2: #7b2fff; + --accent3: #ff2d78; + --accent4: #00ff88; + --text-primary: #dce2f0; + --text-secondary: #6b7394; + --glass: rgba(12, 16, 32, 0.7); + --cell-bg: rgba(255, 255, 255, 0.02); + --cell-border: rgba(255, 255, 255, 0.05); + --card-bg: rgba(255, 255, 255, 0.025); + --ring-bg: rgba(255, 255, 255, 0.04); + --divider: rgba(255, 255, 255, 0.05); + --input-bg: rgba(0, 0, 0, 0.4); + --radius: 18px; + } + + [data-theme="light"] { + --bg-deep: #eef1f5; + --bg-surface: rgba(255, 255, 255, 0.9); + --border-glow: rgba(0, 160, 200, 0.15); + --accent: #0090c0; + --accent2: #6a20e0; + --accent3: #d02060; + --accent4: #00a060; + --text-primary: #1a1d30; + --text-secondary: #5a6088; + --glass: rgba(255, 255, 255, 0.8); + --cell-bg: rgba(0, 0, 0, 0.03); + --cell-border: rgba(0, 0, 0, 0.06); + --card-bg: rgba(0, 0, 0, 0.03); + --ring-bg: rgba(0, 0, 0, 0.06); + --divider: rgba(0, 0, 0, 0.06); + --input-bg: rgba(0, 0, 0, 0.05); + } + + body { + font-family: 'JetBrains Mono', monospace; + background: var(--bg-deep); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; + } + + /* Animated grid background */ + .bg-grid { + position: fixed; inset: 0; z-index: 0; + background-image: + linear-gradient(rgba(0, 240, 255, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 240, 255, 0.025) 1px, transparent 1px); + background-size: 64px 64px; + animation: gridScroll 20s linear infinite; + pointer-events: none; + } + @keyframes gridScroll { + 0% { background-position: 0 0; } + 100% { background-position: 64px 64px; } + } + + /* Floating orbs */ + .orb { + position: fixed; border-radius: 50%; filter: blur(140px); pointer-events: none; + animation: orbFloat 18s ease-in-out infinite; + } + .orb-1 { width: 600px; height: 600px; background: rgba(0, 240, 255, 0.04); top: -250px; left: -150px; } + .orb-2 { width: 500px; height: 500px; background: rgba(123, 47, 255, 0.04); bottom: -200px; right: -100px; animation-delay: -6s; } + .orb-3 { width: 400px; height: 400px; background: rgba(255, 45, 120, 0.03); top: 40%; left: 40%; animation-delay: -12s; } + @keyframes orbFloat { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(50px, -40px) scale(1.06); } + 66% { transform: translate(-30px, 30px) scale(0.94); } + } + + .container { + position: relative; z-index: 1; + max-width: 1600px; margin: 0 auto; padding: 24px 20px; + } + + /* Header */ + .header { + display: flex; align-items: center; justify-content: space-between; + padding: 18px 28px; margin-bottom: 24px; + background: var(--glass); + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--border-glow); border-radius: var(--radius); + } + .header-left { display: flex; align-items: center; gap: 14px; } + .logo-icon { + width: 44px; height: 44px; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + border-radius: 11px; font-size: 22px; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 0 28px rgba(0, 240, 255, 0.35); + animation: logoPulse 3s ease-in-out infinite; + } + @keyframes logoPulse { + 0%, 100% { box-shadow: 0 0 22px rgba(0, 240, 255, 0.3); } + 50% { box-shadow: 0 0 44px rgba(0, 240, 255, 0.6); } + } + .header h1 { + font-family: 'Orbitron', sans-serif; font-size: 20px; font-weight: 700; letter-spacing: 3px; + background: linear-gradient(90deg, var(--accent), var(--accent2)); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; + } + .header-sub { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; } + .header-right { display: flex; align-items: center; gap: 18px; } + .header-date { + font-size: 10px; color: var(--text-secondary); letter-spacing: 1px; + text-align: right; + } + .header-date .date-val { + font-family: 'Orbitron', sans-serif; font-size: 14px; color: var(--accent); display: block; + } + .live-chip { + display: flex; align-items: center; gap: 8px; + font-size: 9px; letter-spacing: 1.5px; padding: 5px 12px; border-radius: 12px; + font-weight: 600; text-transform: uppercase; + background: rgba(0, 255, 136, 0.1); color: var(--accent4); + border: 1px solid rgba(0, 255, 136, 0.2); + } + .live-chip .dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--accent4); box-shadow: 0 0 8px var(--accent4); + animation: blink 1.5s ease-in-out infinite; + } + @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } + + .theme-toggle { + background: none; border: 1px solid var(--cell-border); + border-radius: 8px; padding: 6px 10px; cursor: pointer; + font-size: 16px; line-height: 1; + transition: border-color 0.3s, background 0.3s; + } + .theme-toggle:hover { border-color: var(--accent); background: var(--cell-bg); } + + /* Section title */ + .section-title { + font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px; + margin-bottom: 16px; + background: linear-gradient(90deg, var(--accent), var(--accent2)); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; + display: inline-block; + } + + /* Summary Cards */ + .summary-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 24px; + } + .s-card { + background: var(--glass); + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--border-glow); border-radius: var(--radius); + padding: 20px 22px; + transition: border-color 0.3s, transform 0.2s; + position: relative; overflow: hidden; + } + .s-card:hover { border-color: rgba(0, 240, 255, 0.3); transform: translateY(-2px); } + .s-card .s-icon { + width: 42px; height: 42px; border-radius: 12px; + display: flex; align-items: center; justify-content: center; + font-size: 18px; margin-bottom: 14px; + } + .s-card .s-icon.live { background: linear-gradient(135deg, var(--accent4), #059669); color: #fff; } + .s-card .s-icon.primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #000; } + .s-card .s-icon.warm { background: linear-gradient(135deg, var(--accent2), var(--accent3)); color: #fff; } + .s-card .s-icon.success { background: linear-gradient(135deg, var(--accent), var(--accent4)); color: #000; } + .s-card .s-chip { + font-size: 8px; text-transform: uppercase; letter-spacing: 2px; padding: 3px 10px; + border-radius: 10px; font-weight: 600; + position: absolute; top: 16px; right: 16px; + } + .s-card .s-chip.live-chip-tag { + background: rgba(0, 255, 136, 0.1); color: var(--accent4); + border: 1px solid rgba(0, 255, 136, 0.2); + } + .s-card .s-chip.live-chip-tag .ldot { + width: 6px; height: 6px; border-radius: 50%; display: inline-block; + background: var(--accent4); margin-right: 4px; + animation: blink 1.5s ease-in-out infinite; + } + .s-card .s-chip.info { + background: rgba(0, 240, 255, 0.08); color: var(--accent); + border: 1px solid rgba(0, 240, 255, 0.15); + } + .s-card .s-val { + font-family: 'Orbitron', sans-serif; font-size: 32px; font-weight: 700; + background: linear-gradient(180deg, #fff 0%, var(--accent) 100%); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; + margin-bottom: 4px; + } + .s-card .s-label { font-size: 11px; color: var(--text-secondary); } + .s-card .s-sub { font-size: 9px; color: var(--text-secondary); margin-top: 4px; } + + .hide-me { display: none; } + + /* Charts + Stats layout */ + .charts-row { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; + margin-bottom: 24px; + } + .panel { + background: var(--glass); + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--border-glow); border-radius: var(--radius); + padding: 24px 26px; + position: relative; overflow: hidden; + } + + .chart-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 20px; flex-wrap: wrap; gap: 12px; + } + .chart-filters { display: flex; gap: 6px; } + .chart-filters button { + font-family: 'JetBrains Mono', monospace; font-size: 10px; + letter-spacing: 1px; padding: 6px 14px; border-radius: 7px; + border: 1px solid var(--cell-border); + background: var(--cell-bg); color: var(--text-secondary); + cursor: pointer; transition: all 0.3s; + } + .chart-filters button:hover { border-color: var(--accent); color: var(--text-primary); } + .chart-filters button.active { + background: linear-gradient(135deg, var(--accent), var(--accent2)); + color: #000; border-color: transparent; font-weight: 600; + } + + .chart-container { position: relative; height: 320px; } + + /* Quick Stats */ + .quick-stat { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px; margin-bottom: 10px; + background: var(--cell-bg); + border: 1px solid var(--cell-border); border-radius: 10px; + transition: border-color 0.3s; + } + .quick-stat:hover { border-color: var(--border-glow); } + .quick-stat .qs-left { display: flex; align-items: center; gap: 12px; } + .quick-stat .qs-icon { + width: 38px; height: 38px; border-radius: 10px; + display: flex; align-items: center; justify-content: center; + font-size: 15px; + } + .quick-stat .qs-icon.primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #000; } + .quick-stat .qs-icon.warm { background: linear-gradient(135deg, var(--accent2), var(--accent3)); color: #fff; } + .quick-stat .qs-icon.success { background: linear-gradient(135deg, var(--accent), var(--accent4)); color: #000; } + .quick-stat .qs-label { font-size: 10px; color: var(--text-secondary); } + .quick-stat .qs-sub { font-size: 8px; color: var(--text-secondary); } + .quick-stat .qs-val { + font-family: 'Orbitron', sans-serif; font-size: 18px; font-weight: 700; + color: var(--text-primary); + } + + .recent-section { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--divider); } + .recent-section .rs-title { + font-size: 10px; text-transform: uppercase; letter-spacing: 3px; + color: var(--text-secondary); margin-bottom: 14px; + } + .recent-item { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; margin-bottom: 6px; + background: var(--cell-bg); + border: 1px solid var(--cell-border); border-radius: 9px; + transition: border-color 0.2s; + } + .recent-item:hover { border-color: var(--border-glow); } + .recent-item .ri-left { display: flex; align-items: center; gap: 10px; } + .recent-item .ri-badge { + width: 32px; height: 32px; border-radius: 8px; + display: flex; align-items: center; justify-content: center; + font-family: 'Orbitron', sans-serif; font-size: 10px; font-weight: 700; + background: linear-gradient(135deg, var(--accent), var(--accent2)); + color: #000; + } + .recent-item .ri-date { font-size: 10px; color: var(--text-primary); } + .recent-item .ri-time { font-size: 8px; color: var(--text-secondary); } + .recent-item .ri-count { + font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 700; + color: var(--text-primary); + } + .recent-item .ri-dur { font-size: 8px; color: var(--text-secondary); text-align: right; } + + /* Table Panel */ + .table-panel { + background: var(--glass); + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--border-glow); border-radius: var(--radius); + padding: 24px 26px; + margin-bottom: 24px; + } + .table-toolbar { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 20px; flex-wrap: wrap; gap: 12px; + } + .table-toolbar .toolbar-group { display: flex; align-items: center; gap: 8px; } + + .table-toolbar input[type="date"] { + font-family: 'JetBrains Mono', monospace; font-size: 11px; + background: var(--input-bg); color: var(--text-primary); + border: 1px solid var(--cell-border); border-radius: 7px; + padding: 7px 12px; outline: none; + transition: border-color 0.3s; + } + .table-toolbar input[type="date"]:focus { border-color: var(--accent); } + + .btn { + font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px; + padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600; + border: none; transition: opacity 0.3s, box-shadow 0.3s; + } + .btn-primary { + background: linear-gradient(135deg, var(--accent), var(--accent2)); + color: #000; + } + .btn-primary:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(0, 240, 255, 0.3); } + .btn-ghost { + background: var(--cell-bg); color: var(--text-secondary); + border: 1px solid var(--cell-border); + } + .btn-ghost:hover { border-color: var(--accent); color: var(--text-primary); } + .btn-export-btn { + background: linear-gradient(135deg, var(--accent), var(--accent4)); + color: #000; + } + .btn-export-btn:hover { opacity: 0.85; box-shadow: 0 0 16px rgba(0, 255, 136, 0.3); } + + table { + width: 100%; border-collapse: collapse; + } + table thead th { + font-size: 8px; text-transform: uppercase; letter-spacing: 2px; + color: var(--text-secondary); text-align: left; + padding-bottom: 12px; border-bottom: 1px solid var(--divider); + } + table tbody td { + padding: 12px 0; font-size: 11px; + border-bottom: 1px solid var(--divider); + color: var(--text-primary); + } + .table-row tr { transition: background 0.2s; cursor: pointer; } + .table-row tr:hover { background: var(--cell-bg); } + + .row-date { display: flex; align-items: center; gap: 10px; } + .row-date .rd-icon { + width: 36px; height: 36px; border-radius: 10px; + display: flex; align-items: center; justify-content: center; + font-size: 13px; + background: var(--cell-bg); color: var(--text-secondary); + border: 1px solid var(--cell-border); + } + .row-date .rd-icon.today { + background: linear-gradient(135deg, var(--accent), var(--accent2)); + color: #000; border: none; + } + .badge { + display: inline-flex; align-items: center; padding: 3px 10px; + border-radius: 10px; font-size: 9px; font-weight: 600; letter-spacing: 1px; + } + .badge-batches { background: rgba(0, 240, 255, 0.08); color: var(--accent); } + .badge-green { background: rgba(0, 255, 136, 0.1); color: var(--accent4); } + .badge-gray { background: var(--cell-bg); color: var(--text-secondary); } + .link-text { font-size: 10px; letter-spacing: 1px; cursor: pointer; transition: color 0.2s; } + .link-text:hover { color: var(--accent2); } + + /* Modal */ + .modal-overlay { + position: fixed; inset: 0; z-index: 100; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + display: flex; align-items: center; justify-content: center; + padding: 20px; + } + .modal-overlay.hidden { display: none; } + .modal-content { + background: var(--bg-surface); + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--border-glow); border-radius: var(--radius); + padding: 28px 30px; + width: 100%; max-width: 900px; max-height: 85vh; overflow-y: auto; + transform: scale(0.95); opacity: 0; + transition: transform 0.2s ease-out, opacity 0.2s ease-out; + } + .modal-content.open { transform: scale(1); opacity: 1; } + .modal-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 20px; padding-bottom: 16px; + border-bottom: 1px solid var(--divider); + } + .modal-header h2 { + font-family: 'Orbitron', sans-serif; font-size: 16px; letter-spacing: 2px; + background: linear-gradient(90deg, var(--accent), var(--accent2)); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; + } + .modal-header .close-btn { + background: none; border: 1px solid var(--cell-border); color: var(--text-secondary); + width: 32px; height: 32px; border-radius: 8px; cursor: pointer; + font-size: 16px; display: flex; align-items: center; justify-content: center; + transition: border-color 0.3s, color 0.3s; + } + .modal-header .close-btn:hover { border-color: var(--accent3); color: var(--accent3); } + .modal-subtitle { font-size: 10px; color: var(--text-secondary); letter-spacing: 1px; margin-top: 4px; } + + .modal-stats { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; + margin-bottom: 20px; + } + .modal-stat { + background: var(--cell-bg); + border: 1px solid var(--cell-border); border-radius: 10px; + padding: 16px; text-align: center; + } + .modal-stat .ms-val { + font-family: 'Orbitron', sans-serif; font-size: 24px; font-weight: 700; + color: var(--text-primary); + } + .modal-stat .ms-label { font-size: 9px; color: var(--text-secondary); letter-spacing: 1px; margin-top: 4px; } + .modal-table-wrap { max-height: 380px; overflow-y: auto; } + + /* Footer */ + .footer { + margin-top: 24px; text-align: center; + font-size: 9px; letter-spacing: 2px; color: var(--text-secondary); + text-transform: uppercase; + } + + /* Scrollbar */ + ::-webkit-scrollbar { width: 6px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { background: var(--text-secondary); border-radius: 3px; } + + /* Responsive */ + @media (max-width: 1200px) { + .summary-cards { grid-template-columns: repeat(2, 1fr); } + .charts-row { grid-template-columns: 1fr; } + } + @media (max-width: 768px) { + .container { padding: 16px 12px; } + .summary-cards { grid-template-columns: 1fr; } + .header { flex-direction: column; gap: 10px; padding: 14px 20px; } + .header-left { flex-direction: column; text-align: center; } + .header-right { flex-wrap: wrap; justify-content: center; } + .s-card .s-val { font-size: 24px; } + .panel { padding: 18px 16px; } + .modal-content { padding: 18px 16px; max-width: 95vw; } + .modal-stats { grid-template-columns: 1fr; } + .table-toolbar { flex-direction: column; align-items: flex-start; } + } + @media (max-width: 480px) { + .container { padding: 10px 8px; } + .header { padding: 12px 14px; border-radius: 12px; } + .header h1 { font-size: 16px; letter-spacing: 2px; } + .logo-icon { width: 36px; height: 36px; font-size: 18px; border-radius: 9px; } + .s-card { padding: 16px; border-radius: 14px; } + .s-card .s-val { font-size: 22px; } + .panel { padding: 14px 12px; border-radius: 14px; } + .chart-container { height: 240px; } + .footer { font-size: 8px; letter-spacing: 1px; } + } + - - -
    -
    -
    -
    -
    - -
    -
    -

    ZenAI APC Dashboard

    -

    Real-time batch counting analytics

    -
    -
    -
    -
    - Counting Day -

    --

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

    --

    -

    Current Batch Count

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

    --

    -

    Total Ayam Potong

    -
    - -- batches -
    -
    - - -
    -
    -
    - -
    - Yesterday -
    -

    --

    -

    Total Ayam Potong

    -
    - -- batches -
    -
    - - -
    -
    -
    - -
    - Average -
    -

    --

    -

    Per Day

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

    --

    -

    --

    -
    - Record -
    -
    -
    - - -
    - -
    -
    -
    -

    Daily Trends

    -

    Object count over time

    -
    -
    - - - -
    -
    -
    - -
    -
    - - -
    -

    Quick Stats

    -
    -
    -
    -
    - -
    -
    -

    Grand Total

    -

    All time

    -
    -
    - -- -
    - -
    -
    -
    - -
    -
    -

    Total Batches

    -

    All time

    -
    -
    - -- -
    - -
    -
    -
    - -
    -
    -

    Avg per Batch

    -

    Overall average

    -
    -
    - -- -
    -
    - -
    -

    Recent Activity

    -
    - -
    -
    -
    -
    - - -
    -
    -
    -

    Daily Records

    -

    Click on a row to view batch details

    -
    -
    - - - - - -
    -
    - -
    - - - - - - - - - - - - - - -
    DateTotal CountBatchesAvg/BatchStatusAction
    -
    -
    -
    - - -