Update frigate_counter to GPIO

This commit is contained in:
2026-04-07 16:08:03 +07:00
parent 0339f178cd
commit c52d608d58
11 changed files with 1194 additions and 41 deletions

View File

@@ -1,23 +0,0 @@
[Unit]
Description=Frigate MQTT Counter Service
After=network.target
[Service]
Type=simple
User=frigate
Group=frigate
WorkingDirectory=/Users/dsutanto/MyEnv/Codes/CLAUDE/karung-masuk-frigate-counter
ExecStart=/Users/dsutanto/MyEnv/Codes/CLAUDE/karung-masuk-frigate-counter/venv/bin/python /Users/dsutanto/MyEnv/Codes/CLAUDE/karung-masuk-frigate-counter/frigate_counter.py
Restart=always
RestartSec=10
# Environment variables for MQTT configuration
Environment=FRIGATE_MQTT_HOST=localhost
Environment=FRIGATE_MQTT_PORT=1883
Environment=REPORT_MQTT_HOST=localhost
Environment=REPORT_MQTT_PORT=1883
Environment=TOPIC=frigate
Environment=SITE_NAME=default
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Frigate MQTT Counter Service
After=network.target
[Service]
Type=simple
User=frigate
Group=frigate
WorkingDirectory=/etc/frigate-counter/karung-masuk
ExecStart=/usr/bin/python3 /etc/frigate-counter/karung-masuk/frigate_counter.py
Restart=always
RestartSec=10
# Environment variables for MQTT configuration
#Environment=FRIGATE_MQTT_HOST=localhost
#Environment=FRIGATE_MQTT_PORT=1883
#Environment=REPORT_MQTT_HOST=localhost
#Environment=REPORT_MQTT_PORT=1883
#Environment=TOPIC=frigate
#Environment=SITE_NAME=default
[Install]
WantedBy=multi-user.target

View File

@@ -13,6 +13,7 @@ import threading
import os
import logging
import json
import requests
from datetime import datetime, date
from typing import Optional
@@ -28,25 +29,32 @@ class FrigateCounter:
# MQTT configuration from environment variables
self.frigate_mqtt_host = os.environ.get('FRIGATE_MQTT_HOST', 'localhost')
self.frigate_mqtt_port = int(os.environ.get('FRIGATE_MQTT_PORT', 1883))
self.report_mqtt_host = os.environ.get('REPORT_MQTT_HOST', 'localhost')
#self.report_mqtt_host = os.environ.get('REPORT_MQTT_HOST', 'localhost')
self.report_mqtt_host = os.environ.get('REPORT_MQTT_HOST', 'mqtt.backone.cloud')
self.report_mqtt_port = int(os.environ.get('REPORT_MQTT_PORT', 1883))
self.top_topic = os.environ.get("TOP_TOPIC", "cpsp")
self.site_name = os.environ.get('SITE_NAME', 'sukawarna')
self.topic = os.environ.get('TOPIC', f"{self.top_topic}/counter/{self.site_name}")
self.camera_name = os.environ.get('CAMERA_NAME', 'kandang_1_karung_masuk')
self.pintu_tutup_zone_name = os.environ.get('PINTU_TUTUP_ZONE_NAME', 'pintu_tutup')
self.pintu_kiri_buka_zone_name = os.environ.get('PINTU_KIRI_BUKA_ZONE_NAME', 'pintu_kiri_buka')
self.pintu_kanan_buka_zone_name = os.environ.get('PINTU_KANAN_BUKA_ZONE_NAME', 'pintu_kanan_buka')
logger.info(f"FRIGATE_MQTT_HOST: {self.frigate_mqtt_host}:{self.frigate_mqtt_port}")
logger.info(f"REPORT_MQTT_HOST: {self.report_mqtt_host}:{self.report_mqtt_port}")
logger.info(f"TOPIC: {self.topic}")
logger.info(f"CAMERA_NAME: {self.camera_name}")
# Webcall to RPi
self.relay_on = os.environ.get('RELAY_ON_URI', 'http://192.168.192.26:5000/relay_on')
self.relay_off = os.environ.get('RELAY_OFF_URI', 'http://192.168.192.26:5000/relay_off')
# Database setup
self.db_path = 'karung_counts.db'
self.db_path = '/etc/frigate-counter/karung-masuk/karung_masuk.db'
self.init_database()
# JSON storage for temporary persistent counter values
self.json_storage_path = 'karung_counters.json'
self.json_storage_path = '/etc/frigate-counter/karung-masuk/karung_masuk.json'
self.init_json_storage()
# State tracking
@@ -62,6 +70,8 @@ class FrigateCounter:
self.timer_active_pintu = False
self.timer_start_time_pintu = None
self.pintu_buka_timer = False
# Load previous counter value on startup
self.load_previous_counter()
@@ -75,6 +85,20 @@ class FrigateCounter:
# Schedule daily reset at midnight
schedule.every().day.at("23:59").do(self.reset_counter)
def action_relay_on(self):
try:
requests.get(self.relay_on, timeout=2)
except requests.exceptions.RequestException as e:
pass
logger.info("Relay ON")
def action_relay_off(self):
try:
requests.get(self.relay_off, timeout=2)
except requests.exceptions.RequestException as e:
pass
logger.info("Relay OFF")
def init_database(self):
"""Initialize SQLite database with required table"""
conn = sqlite3.connect(self.db_path)
@@ -154,7 +178,8 @@ class FrigateCounter:
# Dont detect stationary
stationary = event_after.get("stationary")
if stationary:
#if stationary and:
if stationary and label == "karung":
return
new_zones = [z for z in zones_after if z not in zones_before]
@@ -179,7 +204,7 @@ class FrigateCounter:
self.handle_pintu_kanan_buka(camera_name)
elif label == "karung" and self.timer_active:
self.handle_karung(camera_name, track_id)
elif label == "pintu-tutup" and self.timer_active:
elif label == "pintu-tutup" and self.timer_active and self.pintu_tutup_zone_name in zones_after and not self.pintu_buka_timer:
self.handle_pintu_tutup(camera_name)
except Exception as e:
@@ -194,6 +219,9 @@ class FrigateCounter:
self.timer_active = False
self.seen_objects = {}
# Call RPi
self.action_relay_off()
def handle_pintu_kiri_buka(self, camera_name):
"""Handle detection of pintu-kiri-buka"""
logger.info(f"Detected pintu-kiri-buka on {camera_name}")
@@ -254,19 +282,23 @@ class FrigateCounter:
def start_timer(self):
"""Start Counting"""
#logger.info("Starting 60-minute timer")
logger.info("Start Counting...")
logger.info("Start Counting and Timer pintu-buka 5 minutes")
self.timer_active = True
self.timer_start_time = datetime.now()
self.pintu_buka_timer = True
# Schedule timer expiration check
#timer_thread = threading.Thread(target=self.check_timer_expiration)
#timer_thread.daemon = True
#timer_thread.start()
timer_thread = threading.Thread(target=self.check_timer_expiration_pintu_buka)
timer_thread.daemon = True
timer_thread.start()
# Reset detection flags
self.pintu_kiri_buka_detected = False
self.pintu_kanan_buka_detected = False
# Call to RPI
self.action_relay_on()
def check_timer_expiration_pintu(self):
"""Check if timer has expired (5 minutes)"""
time.sleep(5 * 60) # Wait 5 minutes
@@ -277,22 +309,28 @@ class FrigateCounter:
self.pintu_kiri_buka_detected = False
self.pintu_kanan_buka_detected = False
def check_timer_expiration(self):
"""Check if timer has expired (60 minutes)"""
time.sleep(60 * 60) # Wait 60 minutes
# Call RPi
#if not self.timer_active:
# self.action_relay_off()
if self.timer_active:
logger.info("Timer expired (60 minutes)")
self.timer_active = False
#self.publish_result()
#self.reset_counter()
def check_timer_expiration_pintu_buka(self):
"""Check if timer has expired (5 minutes)"""
time.sleep(5 * 60) # Wait 5 minutes
if self.pintu_buka_timer:
logger.info("Timer Pintu Buka expired (5 minutes)")
self.pintu_buka_timer = False
# Call RPi
#if not self.timer_active:
# self.action_relay_off()
def publish_result(self):
"""Publish counter result to MQTT topic"""
if self.counter > 0:
topic = f"{self.topic}/{self.camera_name}/karung"
#TESTING
topic = f"{self.topic}/{self.camera_name}-check_pintu/karung"
#topic = f"{self.topic}/{self.camera_name}-check_pintu/karung"
message = str(self.counter)
try:
@@ -397,6 +435,7 @@ class FrigateCounter:
# self.save_to_database()
# self.save_to_json(self.camera_name)
# Save current counter value before resetting
self.publish_result()
self.save_to_database()
self.counter = 0
self.save_to_json(self.camera_name)

433
frigate_counter.py.backup Normal file
View File

@@ -0,0 +1,433 @@
#!/usr/bin/env python3
"""
Frigate MQTT Counter Service
Monitors Frigate NVR MQTT events to count "karung" objects
after detecting both "pintu-kiri-buka" and "pintu-kanan-buka"
"""
import paho.mqtt.client as mqtt
import sqlite3
import schedule
import time
import threading
import os
import logging
import json
from datetime import datetime, date
from typing import Optional
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class FrigateCounter:
def __init__(self):
# MQTT configuration from environment variables
self.frigate_mqtt_host = os.environ.get('FRIGATE_MQTT_HOST', 'localhost')
self.frigate_mqtt_port = int(os.environ.get('FRIGATE_MQTT_PORT', 1883))
#self.report_mqtt_host = os.environ.get('REPORT_MQTT_HOST', 'localhost')
self.report_mqtt_host = os.environ.get('REPORT_MQTT_HOST', 'mqtt.backone.cloud')
self.report_mqtt_port = int(os.environ.get('REPORT_MQTT_PORT', 1883))
self.top_topic = os.environ.get("TOP_TOPIC", "cpsp")
self.site_name = os.environ.get('SITE_NAME', 'sukawarna')
self.topic = os.environ.get('TOPIC', f"{self.top_topic}/counter/{self.site_name}")
self.camera_name = os.environ.get('CAMERA_NAME', 'kandang_1_karung_masuk')
self.pintu_tutup_zone_name = os.environ.get('PINTU_TUTUP_ZONE_NAME', 'pintu_tutup')
self.pintu_kiri_buka_zone_name = os.environ.get('PINTU_KIRI_BUKA_ZONE_NAME', 'pintu_kiri_buka')
self.pintu_kanan_buka_zone_name = os.environ.get('PINTU_KANAN_BUKA_ZONE_NAME', 'pintu_kanan_buka')
logger.info(f"FRIGATE_MQTT_HOST: {self.frigate_mqtt_host}:{self.frigate_mqtt_port}")
logger.info(f"REPORT_MQTT_HOST: {self.report_mqtt_host}:{self.report_mqtt_port}")
logger.info(f"TOPIC: {self.topic}")
logger.info(f"CAMERA_NAME: {self.camera_name}")
# Database setup
self.db_path = '/etc/frigate-counter/karung-masuk/karung_masuk.db'
self.init_database()
# JSON storage for temporary persistent counter values
self.json_storage_path = '/etc/frigate-counter/karung-masuk/karung_masuk.json'
self.init_json_storage()
# State tracking
self.pintu_kiri_buka_detected = False
self.pintu_kanan_buka_detected = False
self.timer_active = False
self.timer_start_time = None
self.counter = 0
self.counter_lock = threading.Lock()
self.seen_objects = {}
# State pintu tracking
self.timer_active_pintu = False
self.timer_start_time_pintu = None
self.pintu_buka_timer = False
# Load previous counter value on startup
self.load_previous_counter()
# MQTT clients
self.frigate_client = None
self.report_client = None
# Initialize MQTT clients
self.setup_mqtt_clients()
# Schedule daily reset at midnight
schedule.every().day.at("23:59").do(self.reset_counter)
def init_database(self):
"""Initialize SQLite database with required table"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS karung_counts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
camera_name TEXT NOT NULL,
date DATE NOT NULL,
counter_value INTEGER NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
logger.info("Database initialized")
def init_json_storage(self):
"""Initialize JSON storage file for temporary persistent counter values"""
if not os.path.exists(self.json_storage_path):
# Create empty JSON file with empty dictionary
with open(self.json_storage_path, 'w') as f:
json.dump({}, f)
logger.info("JSON storage initialized")
def setup_mqtt_clients(self):
"""Setup MQTT clients for both Frigate and reporting"""
# Frigate MQTT client (for receiving events)
self.frigate_client = mqtt.Client()
self.frigate_client.on_connect = self.on_frigate_connect
self.frigate_client.on_message = self.on_frigate_message
self.frigate_client.connect(self.frigate_mqtt_host, self.frigate_mqtt_port, 60)
# Reporting MQTT client (for publishing results)
self.report_client = mqtt.Client()
self.report_client.on_connect = self.on_report_connect
self.report_client.connect(self.report_mqtt_host, self.report_mqtt_port, 60)
# Start MQTT client loops in separate threads
self.frigate_client.loop_start()
self.report_client.loop_start()
def on_frigate_connect(self, client, userdata, flags, rc):
"""Callback when connected to Frigate MQTT"""
logger.info("Connected to Frigate MQTT broker")
client.subscribe("frigate/events")
def on_report_connect(self, client, userdata, flags, rc):
"""Callback when connected to reporting MQTT broker"""
logger.info("Connected to reporting MQTT broker")
def on_frigate_message(self, client, userdata, msg):
"""Handle incoming Frigate MQTT messages"""
try:
# Parse the message (assuming JSON format)
#import json
payload = json.loads(msg.payload.decode())
if "after" not in payload:
return
#event_after = payload["after"]
event_after = payload.get("after", {})
event_before = payload.get("before", {})
camera_name = event_after.get("camera", "unknown")
if camera_name != self.camera_name:
return
event_type = event_after.get('type', '')
label = event_after.get("label", "")
track_id = event_after.get("id")
zones_after = event_after.get("entered_zones", [])
zones_before = event_before.get("entered_zones", [])
# Dont detect stationary
stationary = event_after.get("stationary")
if stationary:
return
new_zones = [z for z in zones_after if z not in zones_before]
# debounce per track_id
if camera_name not in self.seen_objects:
self.seen_objects[camera_name] = {}
if track_id in self.seen_objects[camera_name]:
return # sudah dihitung
# Extract object type and camera name
#camera_name = data.get('camera', 'unknown')
#event_type = data.get('type', '')
#label = data.get('label', '')
logger.debug(f"Received message: camera={camera_name}, type={event_type}, label={label}")
# Handle different object types
if label == "pintu-kiri-buka" and not self.timer_active and not self.pintu_kiri_buka_detected:
self.handle_pintu_kiri_buka(camera_name)
elif label == "pintu-kanan-buka" and not self.timer_active and not self.pintu_kanan_buka_detected:
self.handle_pintu_kanan_buka(camera_name)
elif label == "karung" and self.timer_active:
self.handle_karung(camera_name, track_id)
elif label == "pintu-tutup" and self.timer_active and self.pintu_tutup_zone_name in zones_after and not self.pintu_buka_timer:
self.handle_pintu_tutup(camera_name)
except Exception as e:
logger.error(f"Error processing MQTT message: {e}")
def handle_pintu_tutup(self, camera_name):
"""Handle detection of pintu-tutup"""
logger.info(f"Detected pintu-tutup {camera_name}")
if self.timer_active:
logger.info("Stop Counting Karung")
self.timer_active = False
self.seen_objects = {}
def handle_pintu_kiri_buka(self, camera_name):
"""Handle detection of pintu-kiri-buka"""
logger.info(f"Detected pintu-kiri-buka on {camera_name}")
# Only process if timer is not active
if not self.timer_active:
self.pintu_kiri_buka_detected = True
self.check_detection_sequence()
else:
logger.debug("Ignoring pintu-kiri-buka during timer period")
def handle_pintu_kanan_buka(self, camera_name):
"""Handle detection of pintu-kanan-buka"""
logger.info(f"Detected pintu-kanan-buka on {camera_name}")
# Only process if timer is not active
if not self.timer_active:
self.pintu_kanan_buka_detected = True
self.check_detection_sequence()
else:
logger.debug("Ignoring pintu-kanan-buka during timer period")
def handle_karung(self, camera_name, track_id):
"""Handle detection of karung object"""
logger.info(f"Detected karung on {camera_name}")
# Only count if timer is active
if self.timer_active:
with self.counter_lock:
self.counter += 1
self.seen_objects[camera_name][track_id] = datetime.now()
self.save_to_json(camera_name)
self.publish_result()
logger.info(f"Counter incremented to {self.counter} and republish to MQTT")
else:
logger.debug("Ignoring karung outside timer period")
def check_detection_sequence(self):
"""Check if both pintu-kiri-buka and pintu-kanan-buka have been detected"""
if self.pintu_kiri_buka_detected and self.pintu_kanan_buka_detected:
self.start_timer()
return
if not self.timer_active_pintu:
self.start_timer_pintu()
def start_timer_pintu(self):
"""Start the 5-minute timer Pintu"""
logger.info("Starting 5-minute timer Pintu")
self.timer_active_pintu = True
self.timer_start_time_pintu = datetime.now()
# Schedule timer expiration check
timer_thread = threading.Thread(target=self.check_timer_expiration_pintu)
timer_thread.daemon = True
timer_thread.start()
def start_timer(self):
"""Start Counting"""
#logger.info("Starting 60-minute timer")
logger.info("Start Counting and Timer pintu-buka 5 minutes")
self.timer_active = True
self.timer_start_time = datetime.now()
self.pintu_buka_timer = True
# Schedule timer expiration check
timer_thread = threading.Thread(target=self.check_timer_expiration_pintu_buka)
timer_thread.daemon = True
timer_thread.start()
# Reset detection flags
self.pintu_kiri_buka_detected = False
self.pintu_kanan_buka_detected = False
def check_timer_expiration_pintu(self):
"""Check if timer has expired (5 minutes)"""
time.sleep(5 * 60) # Wait 5 minutes
if self.timer_active_pintu:
logger.info("Timer Pintu expired (5 minutes)")
self.timer_active_pintu = False
self.pintu_kiri_buka_detected = False
self.pintu_kanan_buka_detected = False
def check_timer_expiration_pintu_buka(self):
"""Check if timer has expired (5 minutes)"""
time.sleep(5 * 60) # Wait 5 minutes
if self.pintu_buka_timer:
logger.info("Timer Pintu Buka expired (5 minutes)")
self.pintu_buka_timer = False
def publish_result(self):
"""Publish counter result to MQTT topic"""
if self.counter > 0:
topic = f"{self.topic}/{self.camera_name}/karung"
#TESTING
#topic = f"{self.topic}/{self.camera_name}-check_pintu/karung"
message = str(self.counter)
try:
self.report_client.publish(topic, message)
logger.info(f"Published counter result to {topic}: {message}")
# Save to database
#self.save_to_database()
# Save to JSON for temporary persistent storage
#self.save_to_json('frigate_camera')
except Exception as e:
logger.error(f"Error publishing result: {e}")
else:
logger.info("Counter is zero, not publishing result")
def save_to_database(self):
"""Save counter result to SQLite database"""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO karung_counts (camera_name, date, counter_value)
VALUES (?, ?, ?)
''', (self.camera_name, date.today(), self.counter))
conn.commit()
conn.close()
logger.info(f"Saved counter result to database: {self.counter}")
except Exception as e:
logger.error(f"Error saving to database: {e}")
# Ensure we don't lose data due to database errors
# We should still try to save to JSON as backup
try:
self.save_to_json(self.camera_name)
logger.info("Fallback save to JSON successful")
except Exception as e2:
logger.error(f"Fallback save to JSON also failed: {e2}")
def save_to_json(self, camera_name):
"""Save counter result to JSON file for temporary persistent storage"""
try:
# Read existing data
if os.path.exists(self.json_storage_path):
with open(self.json_storage_path, 'r') as f:
data = json.load(f)
else:
data = {}
# Update counter value for this camera
if camera_name not in data:
data[camera_name] = {}
data[camera_name]["karung"] = self.counter
# Write back to file
with open(self.json_storage_path, 'w') as f:
json.dump(data, f, indent=2)
logger.info(f"Saved counter result to JSON storage for {camera_name}: {self.counter}")
except Exception as e:
logger.error(f"Error saving to JSON storage: {e}")
# Try to create a backup of the existing file before overwriting
try:
import shutil
backup_path = f"{self.json_storage_path}.backup"
if os.path.exists(self.json_storage_path):
shutil.copy2(self.json_storage_path, backup_path)
logger.info(f"Created backup of JSON storage: {backup_path}")
except Exception as backup_e:
logger.error(f"Failed to create backup of JSON storage: {backup_e}")
raise # Re-raise the original exception
def load_previous_counter(self):
"""Load the previous counter value from JSON storage on startup"""
try:
self.counter = self.load_from_json(self.camera_name)
logger.info(f"Loaded previous counter value: {self.counter}")
except Exception as e:
logger.error(f"Error loading previous counter value: {e}")
# If loading fails, start with 0 counter
self.counter = 0
def load_from_json(self, camera_name):
"""Load counter result from JSON file"""
try:
if os.path.exists(self.json_storage_path):
with open(self.json_storage_path, 'r') as f:
data = json.load(f)
if camera_name in data and "karung" in data[camera_name]:
return data[camera_name]["karung"]
return 0
except Exception as e:
logger.error(f"Error loading from JSON storage: {e}")
# Return 0 in case of error for safety
return 0
def reset_counter(self):
"""Reset counter and clear detection flags"""
logger.info("Resetting counter at midnight")
#with self.counter_lock:
# self.counter = 0
# self.save_to_database()
# self.save_to_json(self.camera_name)
# Save current counter value before resetting
self.publish_result()
self.save_to_database()
self.counter = 0
self.save_to_json(self.camera_name)
self.pintu_kiri_buka_detected = False
self.pintu_kanan_buka_detected = False
self.timer_active = False
self.timer_start_time = None
self.seen_objects = {}
def run(self):
"""Main run loop"""
logger.info("Starting Frigate Counter Service")
try:
while True:
# Run scheduled tasks
schedule.run_pending()
time.sleep(1)
except KeyboardInterrupt:
logger.info("Shutting down Frigate Counter Service")
self.frigate_client.loop_stop()
self.report_client.loop_stop()
self.frigate_client.disconnect()
self.report_client.disconnect()
if __name__ == "__main__":
counter_service = FrigateCounter()
counter_service.run()

Binary file not shown.

5
karung_masuk.json Normal file
View File

@@ -0,0 +1,5 @@
{
"kandang_1_karung_masuk": {
"karung": 41
}
}

222
templates/base.html Normal file
View File

@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Frigate Counter Karung Masuk{% endblock %}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: #2c3e50;
color: white;
padding: 20px 0;
margin-bottom: 30px;
}
header h1 {
text-align: center;
font-size: 1.8rem;
}
header nav {
text-align: center;
margin-top: 10px;
}
header nav a {
color: white;
text-decoration: none;
margin: 0 15px;
padding: 5px 10px;
border-radius: 4px;
transition: background 0.3s;
}
header nav a:hover {
background: rgba(255,255,255,0.2);
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card h2 {
margin-bottom: 15px;
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #3498db;
color: white;
font-weight: 600;
}
tr:hover {
background: #f8f9fa;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 600;
}
.badge-primary {
background: #3498db;
color: white;
}
.badge-success {
background: #27ae60;
color: white;
}
.badge-info {
background: #17a2b8;
color: white;
}
a {
color: #3498db;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 20px;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-card h3 {
font-size: 2rem;
color: #3498db;
margin-bottom: 5px;
}
.stat-card p {
color: #666;
font-size: 0.9rem;
}
.empty-state {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state h3 {
margin-bottom: 10px;
color: #999;
}
.date-selector {
margin-bottom: 20px;
}
.date-selector select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.btn {
display: inline-block;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
transition: background 0.3s;
font-size: 0.9rem;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
text-decoration: none;
}
footer {
text-align: center;
padding: 20px;
color: #666;
margin-top: 40px;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<header>
<div class="container">
<h1>Frigate Counter Karung Masuk</h1>
<nav>
<a href="{{ url_for('index') }}">Dashboard</a>
</nav>
</div>
</header>
<div class="container">
{% block content %}{% endblock %}
</div>
<footer>
<p>Frigate Counter - Real-time bag counting system</p>
</footer>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}{{ camera_name }} - Camera Details{% endblock %}
{% block content %}
<div class="card">
<h2>Camera: {{ camera_name }}</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>{{ counts|length }}</h3>
<p>Days with Data</p>
</div>
<div class="stat-card">
<h3>{{ total }}</h3>
<p>Total Counts</p>
</div>
<div class="stat-card">
<h3>{{ (total / counts|length)|round(1) if counts|length > 0 else 0 }}</h3>
<p>Average per Day</p>
</div>
</div>
{% if counts %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Count</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
{% for item in counts %}
<tr>
<td><a href="{{ url_for('date_detail', date_str=item.date) }}">{{ item.date }}</a></td>
<td><span class="badge badge-success">{{ item.counter_value }}</span></td>
<td>{{ item.timestamp }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<h3>No data for this camera</h3>
<p>There are no records for camera {{ camera_name }}.</p>
</div>
{% endif %}
<a href="{{ url_for('index') }}" class="btn btn-primary">← Back to Dashboard</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}{{ date }} - Daily Details{% endblock %}
{% block content %}
<div class="card">
<h2>Details for {{ date }}</h2>
<div class="date-selector">
<label for="date-select">Jump to date: </label>
<select id="date-select" onchange="window.location.href=this.value">
<option value="">Select a date...</option>
{% for d in available_dates %}
<option value="{{ url_for('date_detail', date_str=d) }}" {% if d == date %}selected{% endif %}>{{ d }}</option>
{% endfor %}
</select>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>{{ counts|length }}</h3>
<p>Camera Entries</p>
</div>
<div class="stat-card">
<h3>{{ total }}</h3>
<p>Total Counts</p>
</div>
</div>
{% if counts %}
<table>
<thead>
<tr>
<th>Camera Name</th>
<th>Count</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
{% for item in counts %}
<tr>
<td><a href="{{ url_for('camera_detail', camera_name=item.camera_name) }}">{{ item.camera_name }}</a></td>
<td><span class="badge badge-success">{{ item.counter_value }}</span></td>
<td>{{ item.timestamp }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<h3>No data for this date</h3>
<p>There are no records for {{ date }}.</p>
</div>
{% endif %}
<a href="{{ url_for('index') }}" class="btn btn-primary">← Back to Dashboard</a>
</div>
{% endblock %}

123
templates/index.html Normal file
View File

@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}Dashboard - Frigate Counter{% endblock %}
{% block content %}
<!-- Today's Stats -->
<div class="stats-grid">
<div class="stat-card">
<h3>{{ today_data|length }}</h3>
<p>Cameras Active Today</p>
</div>
<div class="stat-card">
<h3>{{ today_data|sum(attribute='counter_value') }}</h3>
<p>Total Counts Today</p>
</div>
<div class="stat-card">
<h3>{{ summary|length }}</h3>
<p>Days with Data</p>
</div>
<div class="stat-card">
<h3>{{ cameras|sum(attribute='total_count') }}</h3>
<p>All Time Total</p>
</div>
</div>
<!-- Today's Details -->
<div class="card">
<h2>Today's Activity ({{ today }})</h2>
{% if today_data %}
<table>
<thead>
<tr>
<th>Camera</th>
<th>Count</th>
<th>Last Updated</th>
</tr>
</thead>
<tbody>
{% for item in today_data %}
<tr>
<td><a href="{{ url_for('camera_detail', camera_name=item.camera_name) }}">{{ item.camera_name }}</a></td>
<td><span class="badge badge-success">{{ item.counter_value }}</span></td>
<td>{{ item.timestamp }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<h3>No data for today yet</h3>
<p>Counts will appear here once the counter starts receiving events.</p>
</div>
{% endif %}
</div>
<!-- Daily Summary -->
<div class="card">
<h2>Daily Summary</h2>
{% if summary %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Cameras</th>
<th>Total Counts</th>
<th>Last Update</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for day in summary %}
<tr>
<td><strong>{{ day.date }}</strong></td>
<td><span class="badge badge-info">{{ day.camera_count }}</span></td>
<td><span class="badge badge-success">{{ day.total_count }}</span></td>
<td>{{ day.last_update }}</td>
<td><a href="{{ url_for('date_detail', date_str=day.date) }}" class="btn btn-primary">View Details</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<h3>No data available</h3>
<p>Daily summaries will appear here once data is recorded.</p>
</div>
{% endif %}
</div>
<!-- Camera Summary -->
<div class="card">
<h2>Camera Summary</h2>
{% if cameras %}
<table>
<thead>
<tr>
<th>Camera Name</th>
<th>Days Active</th>
<th>Total Counts</th>
<th>Last Update</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for camera in cameras %}
<tr>
<td><strong>{{ camera.camera_name }}</strong></td>
<td><span class="badge badge-info">{{ camera.day_count }}</span></td>
<td><span class="badge badge-success">{{ camera.total_count }}</span></td>
<td>{{ camera.last_update }}</td>
<td><a href="{{ url_for('camera_detail', camera_name=camera.camera_name) }}" class="btn btn-primary">View Details</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<h3>No cameras registered</h3>
<p>Camera data will appear here once events are processed.</p>
</div>
{% endif %}
</div>
{% endblock %}

221
web_app.py Normal file
View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Flask Web Application for Frigate Counter
Displays daily karung counts from SQLite database
"""
from flask import Flask, render_template, jsonify, request
import sqlite3
from datetime import datetime, date, timedelta
from typing import List, Dict, Optional
import os
app = Flask(__name__)
DB_PATH = 'karung_counts.db'
def get_db_connection():
"""Create a database connection"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def get_daily_counts(selected_date: Optional[str] = None) -> List[Dict]:
"""Get counts for a specific date or all dates"""
conn = get_db_connection()
cursor = conn.cursor()
if selected_date:
cursor.execute('''
SELECT camera_name, date, counter_value, timestamp
FROM karung_counts
WHERE date = ?
ORDER BY timestamp DESC
''', (selected_date,))
else:
cursor.execute('''
SELECT camera_name, date, counter_value, timestamp
FROM karung_counts
ORDER BY date DESC, timestamp DESC
''')
rows = cursor.fetchall()
conn.close()
return [{
'camera_name': row['camera_name'],
'date': row['date'],
'counter_value': row['counter_value'],
'timestamp': row['timestamp']
} for row in rows]
def get_summary_by_date() -> List[Dict]:
"""Get daily summary grouped by date"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT
date,
COUNT(DISTINCT camera_name) as camera_count,
SUM(counter_value) as total_count,
MAX(timestamp) as last_update
FROM karung_counts
GROUP BY date
ORDER BY date DESC
''')
rows = cursor.fetchall()
conn.close()
return [{
'date': row['date'],
'camera_count': row['camera_count'],
'total_count': row['total_count'] or 0,
'last_update': row['last_update']
} for row in rows]
def get_available_dates() -> List[str]:
"""Get list of all dates with data"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT DISTINCT date
FROM karung_counts
ORDER BY date DESC
''')
rows = cursor.fetchall()
conn.close()
return [row['date'] for row in rows]
def get_camera_summary() -> List[Dict]:
"""Get summary by camera"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT
camera_name,
COUNT(DISTINCT date) as day_count,
SUM(counter_value) as total_count,
MAX(timestamp) as last_update
FROM karung_counts
GROUP BY camera_name
ORDER BY camera_name
''')
rows = cursor.fetchall()
conn.close()
return [{
'camera_name': row['camera_name'],
'day_count': row['day_count'],
'total_count': row['total_count'] or 0,
'last_update': row['last_update']
} for row in rows]
@app.route('/')
def index():
"""Main page showing daily summary"""
summary = get_summary_by_date()
cameras = get_camera_summary()
available_dates = get_available_dates()
today = date.today().isoformat()
today_data = get_daily_counts(today)
return render_template('index.html',
summary=summary,
cameras=cameras,
today_data=today_data,
available_dates=available_dates,
today=today)
@app.route('/date/<date_str>')
def date_detail(date_str: str):
"""Show details for a specific date"""
counts = get_daily_counts(date_str)
available_dates = get_available_dates()
# Calculate total for this date
total = sum(c['counter_value'] for c in counts)
return render_template('date_detail.html',
date=date_str,
counts=counts,
total=total,
available_dates=available_dates)
@app.route('/camera/<camera_name>')
def camera_detail(camera_name: str):
"""Show details for a specific camera"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT date, counter_value, timestamp
FROM karung_counts
WHERE camera_name = ?
ORDER BY date DESC
''', (camera_name,))
rows = cursor.fetchall()
conn.close()
counts = [{
'date': row['date'],
'counter_value': row['counter_value'],
'timestamp': row['timestamp']
} for row in rows]
total = sum(c['counter_value'] for c in counts)
return render_template('camera_detail.html',
camera_name=camera_name,
counts=counts,
total=total)
@app.route('/api/counts')
def api_counts():
"""API endpoint to get all counts"""
date_filter = request.args.get('date')
counts = get_daily_counts(date_filter)
return jsonify({
'counts': counts,
'date_filter': date_filter
})
@app.route('/api/summary')
def api_summary():
"""API endpoint to get daily summary"""
summary = get_summary_by_date()
return jsonify({'summary': summary})
@app.route('/api/cameras')
def api_cameras():
"""API endpoint to get camera summary"""
cameras = get_camera_summary()
return jsonify({'cameras': cameras})
if __name__ == '__main__':
# Ensure database exists
if not os.path.exists(DB_PATH):
print(f"Error: Database {DB_PATH} not found!")
exit(1)
# Run Flask app on all interfaces, port 5000
app.run(host='0.0.0.0', port=8898, debug=True)