initial
This commit is contained in:
commit
a9219e21da
20 files changed
+2215
No files matched your search
@@ -0,0 +1,98 @@
|
||||
# Frigate MQTT Counter Service
|
||||
|
||||
This service monitors Frigate NVR MQTT events to count "karung" objects after detecting both "pintu-kiri-buka" and "pintu-kanan-buka" objects.
|
||||
|
||||
## Features
|
||||
|
||||
- Monitor MQTT events from Frigate NVR on topic `frigate/events`
|
||||
- Detect both "pintu-kiri-buka" and "pintu-kanan-buka" objects
|
||||
- Start 30-minute timer when both objects detected
|
||||
- Count "karung" objects during timer period
|
||||
- Ignore "pintu-kiri-buka" and "pintu-kanan-buka" during timer
|
||||
- Save results to SQLite database with columns: camera_name, date, counter_value
|
||||
- Republish counter result to MQTT topic `{TOPIC}/counter/{SITE_NAME}`
|
||||
- Reset counter every midnight
|
||||
- If timer expires (more than 30 minutes), restart detection sequence
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.6+
|
||||
- paho-mqtt
|
||||
- schedule
|
||||
- sqlite3 (built-in with Python)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Create a virtual environment:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
- `FRIGATE_MQTT_HOST`: Host for Frigate MQTT server (default: localhost)
|
||||
- `FRIGATE_MQTT_PORT`: Port for Frigate MQTT server (default: 1883)
|
||||
- `REPORT_MQTT_HOST`: Host for reporting MQTT server (default: localhost)
|
||||
- `REPORT_MQTT_PORT`: Port for reporting MQTT server (default: 1883)
|
||||
- `TOPIC`: Base topic for reporting MQTT server (default: frigate)
|
||||
- `SITE_NAME`: Site name for reporting MQTT server (default: default)
|
||||
|
||||
## Usage
|
||||
|
||||
Run the service:
|
||||
```bash
|
||||
python frigate_counter.py
|
||||
```
|
||||
|
||||
## Systemd Service
|
||||
|
||||
To run as a systemd service, copy the service file to `/etc/systemd/system/`:
|
||||
```bash
|
||||
sudo cp frigate-counter.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable frigate-counter.service
|
||||
sudo systemctl start frigate-counter.service
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The service creates an SQLite database (`karung_counts.db`) with the following table:
|
||||
|
||||
```sql
|
||||
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
|
||||
);
|
||||
```
|
||||
|
||||
## MQTT Topics
|
||||
|
||||
- Subscribe to: `frigate/events`
|
||||
- Publish to: `{TOPIC}/counter/{SITE_NAME}`
|
||||
|
||||
## Architecture
|
||||
|
||||
The service implements a state machine with the following states:
|
||||
1. Waiting for "pintu-kiri-buka" detection
|
||||
2. Waiting for "pintu-kanan-buka" detection
|
||||
3. Timer active - counting "karung" objects
|
||||
4. Timer expired - publishing results and resetting
|
||||
|
||||
## Error Handling
|
||||
|
||||
The service includes comprehensive error handling and logging for:
|
||||
- MQTT connection issues
|
||||
- Database errors
|
||||
- Message parsing errors
|
||||
- Timer expiration handling
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Example usage script for Frigate MQTT Counter Service
|
||||
|
||||
echo "Example usage of Frigate MQTT Counter Service"
|
||||
|
||||
echo ""
|
||||
echo "To run with custom MQTT settings:"
|
||||
echo "FRIGATE_MQTT_HOST=192.168.1.100"
|
||||
echo "FRIGATE_MQTT_PORT=1883"
|
||||
echo "REPORT_MQTT_HOST=192.168.1.101"
|
||||
echo "REPORT_MQTT_PORT=1883"
|
||||
echo "TOPIC=frigate/events"
|
||||
echo "SITE_NAME=warehouse-1"
|
||||
echo "python frigate_counter.py"
|
||||
|
||||
echo ""
|
||||
echo "To run with default settings:"
|
||||
echo "python frigate_counter.py"
|
||||
|
||||
echo ""
|
||||
echo "To test the installation:"
|
||||
echo "./install.sh"
|
||||
echo "python test_frigate_counter.py"
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
INDONESIA_TIMEZONES = {
|
||||
"Asia/Jakarta": "WIB (Asia/Jakarta)",
|
||||
"Asia/Makassar": "WITA (Asia/Makassar)",
|
||||
"Asia/Jayapura": "WIT (Asia/Jayapura)",
|
||||
}
|
||||
|
||||
DEFAULT_LOCAL_TIMEZONE = "Asia/Jakarta"
|
||||
DEFAULT_TUANG_CUTOFF_TIME = "17:00:00"
|
||||
DEFAULT_MASUK_CUTOFF_TIME = "24:00:00"
|
||||
COUNTER_SETTINGS_FILE_NAME = "counter_settings.json"
|
||||
|
||||
|
||||
def default_counter_settings():
|
||||
return {
|
||||
"local_timezone": DEFAULT_LOCAL_TIMEZONE,
|
||||
"tuang_cutoff_time": DEFAULT_TUANG_CUTOFF_TIME,
|
||||
"masuk_cutoff_time": DEFAULT_MASUK_CUTOFF_TIME,
|
||||
}
|
||||
|
||||
|
||||
def get_settings_file_candidates(base_dir=None):
|
||||
current_dir = Path(base_dir or __file__).resolve().parent
|
||||
candidates = []
|
||||
|
||||
env_path = os.environ.get("COUNTER_SETTINGS_FILE")
|
||||
if env_path:
|
||||
candidates.append(Path(env_path).expanduser())
|
||||
|
||||
candidates.extend(
|
||||
[
|
||||
current_dir / COUNTER_SETTINGS_FILE_NAME,
|
||||
current_dir.parent / COUNTER_SETTINGS_FILE_NAME,
|
||||
current_dir.parent / "karung-web-admin" / COUNTER_SETTINGS_FILE_NAME,
|
||||
current_dir.parent / "karung-web" / COUNTER_SETTINGS_FILE_NAME,
|
||||
Path("/etc/frigate-counter/karung-web-admin") / COUNTER_SETTINGS_FILE_NAME,
|
||||
Path("/etc/frigate-counter/karung-web") / COUNTER_SETTINGS_FILE_NAME,
|
||||
]
|
||||
)
|
||||
|
||||
unique_candidates = []
|
||||
seen_paths = set()
|
||||
for candidate in candidates:
|
||||
resolved_candidate = candidate.resolve()
|
||||
if resolved_candidate in seen_paths:
|
||||
continue
|
||||
unique_candidates.append(candidate)
|
||||
seen_paths.add(resolved_candidate)
|
||||
|
||||
return unique_candidates
|
||||
|
||||
|
||||
def normalize_timezone(timezone_name):
|
||||
timezone_name = (timezone_name or DEFAULT_LOCAL_TIMEZONE).strip()
|
||||
if timezone_name not in INDONESIA_TIMEZONES:
|
||||
allowed_timezones = ", ".join(INDONESIA_TIMEZONES)
|
||||
raise ValueError(f"Timezone must be one of: {allowed_timezones}.")
|
||||
return timezone_name
|
||||
|
||||
|
||||
def normalize_cutoff_time(cutoff_time):
|
||||
cutoff_time = (cutoff_time or "").strip()
|
||||
parts = cutoff_time.split(":")
|
||||
|
||||
if len(parts) == 2:
|
||||
parts.append("00")
|
||||
|
||||
if len(parts) != 3:
|
||||
raise ValueError("Cutoff time must use HH:MM or HH:MM:SS format.")
|
||||
|
||||
try:
|
||||
hour, minute, second = [int(part) for part in parts]
|
||||
except ValueError as error:
|
||||
raise ValueError("Cutoff time must contain only numbers.") from error
|
||||
|
||||
if hour == 24 and minute == 0 and second == 0:
|
||||
return "24:00:00"
|
||||
if hour < 0 or hour > 23:
|
||||
raise ValueError("Cutoff hour must be between 00 and 24.")
|
||||
if minute < 0 or minute > 59:
|
||||
raise ValueError("Cutoff minute must be between 00 and 59.")
|
||||
if second < 0 or second > 59:
|
||||
raise ValueError("Cutoff second must be between 00 and 59.")
|
||||
|
||||
return f"{hour:02d}:{minute:02d}:{second:02d}"
|
||||
|
||||
|
||||
def parse_cutoff_time(cutoff_time):
|
||||
normalized_cutoff = normalize_cutoff_time(cutoff_time)
|
||||
hour, minute, second = [int(part) for part in normalized_cutoff.split(":")]
|
||||
return time(hour=hour, minute=minute, second=second)
|
||||
|
||||
|
||||
def cutoff_time_to_seconds(cutoff_time):
|
||||
normalized_cutoff = normalize_cutoff_time(cutoff_time)
|
||||
hour, minute, second = [int(part) for part in normalized_cutoff.split(":")]
|
||||
return (hour * 60 * 60) + (minute * 60) + second
|
||||
|
||||
|
||||
def time_to_seconds(value):
|
||||
return (value.hour * 60 * 60) + (value.minute * 60) + value.second
|
||||
|
||||
|
||||
def normalize_counter_settings(settings):
|
||||
normalized_settings = default_counter_settings()
|
||||
normalized_settings.update(settings or {})
|
||||
normalized_settings["local_timezone"] = normalize_timezone(
|
||||
normalized_settings.get("local_timezone")
|
||||
)
|
||||
normalized_settings["tuang_cutoff_time"] = normalize_cutoff_time(
|
||||
normalized_settings.get("tuang_cutoff_time")
|
||||
)
|
||||
normalized_settings["masuk_cutoff_time"] = normalize_cutoff_time(
|
||||
normalized_settings.get("masuk_cutoff_time")
|
||||
)
|
||||
return normalized_settings
|
||||
|
||||
|
||||
def load_counter_settings(base_dir=None, overrides=None):
|
||||
settings = default_counter_settings()
|
||||
|
||||
for settings_file in get_settings_file_candidates(base_dir):
|
||||
if not settings_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
with settings_file.open() as file_handle:
|
||||
loaded_settings = json.load(file_handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
if isinstance(loaded_settings, dict):
|
||||
settings.update(loaded_settings)
|
||||
break
|
||||
|
||||
if overrides:
|
||||
settings.update(overrides)
|
||||
|
||||
try:
|
||||
return normalize_counter_settings(settings)
|
||||
except ValueError:
|
||||
return default_counter_settings()
|
||||
|
||||
|
||||
def get_timezone(settings):
|
||||
return ZoneInfo(normalize_timezone(settings.get("local_timezone")))
|
||||
|
||||
|
||||
def get_local_now(settings=None, now=None):
|
||||
settings = normalize_counter_settings(settings)
|
||||
local_timezone = get_timezone(settings)
|
||||
|
||||
if now is None:
|
||||
return datetime.now(local_timezone)
|
||||
if now.tzinfo is None:
|
||||
return now.replace(tzinfo=local_timezone)
|
||||
return now.astimezone(local_timezone)
|
||||
|
||||
|
||||
def get_cutoff_value(settings, counter_type):
|
||||
settings = normalize_counter_settings(settings)
|
||||
if counter_type == "tuang":
|
||||
return settings["tuang_cutoff_time"]
|
||||
if counter_type == "masuk":
|
||||
return settings["masuk_cutoff_time"]
|
||||
raise ValueError("Counter type must be 'tuang' or 'masuk'.")
|
||||
|
||||
|
||||
def get_business_date(counter_type, settings=None, now=None):
|
||||
settings = normalize_counter_settings(settings)
|
||||
local_now = get_local_now(settings, now)
|
||||
cutoff_seconds = cutoff_time_to_seconds(get_cutoff_value(settings, counter_type))
|
||||
|
||||
if time_to_seconds(local_now.time()) >= cutoff_seconds:
|
||||
return local_now.date() + timedelta(days=1)
|
||||
return local_now.date()
|
||||
|
||||
|
||||
def get_finished_business_date(counter_type, settings=None, now=None):
|
||||
settings = normalize_counter_settings(settings)
|
||||
local_now = get_local_now(settings, now)
|
||||
cutoff_seconds = cutoff_time_to_seconds(get_cutoff_value(settings, counter_type))
|
||||
active_business_date = get_business_date(counter_type, settings, local_now)
|
||||
|
||||
if time_to_seconds(local_now.time()) >= cutoff_seconds:
|
||||
return active_business_date - timedelta(days=1)
|
||||
return active_business_date
|
||||
@@ -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
|
||||
@@ -0,0 +1,670 @@
|
||||
#!/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 time
|
||||
import threading
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from counter_time import (
|
||||
get_business_date,
|
||||
get_finished_business_date,
|
||||
get_local_now,
|
||||
load_counter_settings,
|
||||
)
|
||||
|
||||
# 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}")
|
||||
|
||||
# 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 = "/etc/frigate-counter/karung-masuk/karung_masuk.db"
|
||||
#self.db_path = "/tmp/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.json_storage_path = "/tmp/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
|
||||
|
||||
# Counter IN/DOWN
|
||||
self.counter = 0
|
||||
self.counter_lock = threading.Lock()
|
||||
|
||||
# Counter OUT/UP
|
||||
self.counter_out = 0
|
||||
|
||||
self.seen_objects = {}
|
||||
|
||||
# State pintu tracking
|
||||
self.timer_active_pintu = False
|
||||
self.timer_start_time_pintu = None
|
||||
self.timer_active_pintu_tutup = False
|
||||
self.timer_start_time_pintu_tutup = 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()
|
||||
|
||||
self.path_movement_threshold = float(
|
||||
os.environ.get("PATH_MOVEMENT_THRESHOLD", "0.05")
|
||||
)
|
||||
self.active_business_date = self.get_current_business_date()
|
||||
logger.info(f"Active masuk business date: {self.active_business_date}")
|
||||
|
||||
# ==================== START - DIRECTION ====================
|
||||
def _get_path_y_values(self, path_data: list) -> List[float]:
|
||||
y_values = []
|
||||
for path_item in path_data or []:
|
||||
try:
|
||||
point = path_item[0]
|
||||
y_values.append(float(point[1]))
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
return y_values
|
||||
|
||||
def calculate_direction_from_path(self, path_data: list) -> Optional[str]:
|
||||
y_values = self._get_path_y_values(path_data)
|
||||
if len(y_values) < 2:
|
||||
return None
|
||||
|
||||
delta_y = y_values[-1] - y_values[0]
|
||||
if abs(delta_y) < self.path_movement_threshold:
|
||||
return None
|
||||
|
||||
if delta_y < 0:
|
||||
return "UP"
|
||||
return "DOWN"
|
||||
|
||||
# ==================== END - DIRECTION ====================
|
||||
|
||||
# ==================== START - RELAY LAMPU ====================
|
||||
def action_relay_on(self):
|
||||
try:
|
||||
requests.get(self.relay_on, timeout=1)
|
||||
logger.info("Relay ON")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.info(e)
|
||||
|
||||
def action_relay_off(self):
|
||||
try:
|
||||
requests.get(self.relay_off, timeout=2)
|
||||
logger.info("Relay OFF")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.info(e)
|
||||
|
||||
# ==================== END - RELAY LAMPU ====================
|
||||
#
|
||||
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 = payload.get("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 and:
|
||||
if stationary and label == "karung" and event_type != "end":
|
||||
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}, zones_after={zones_after}, zones_before={zones_before}, timer_active={self.timer_active}, pintu_buka_timer={self.pintu_buka_timer}"
|
||||
)
|
||||
|
||||
after_data = event_after.get("data", {}) if isinstance(event_after, dict) else {}
|
||||
before_data = event_before.get("data", {}) if isinstance(event_before, dict) else {}
|
||||
|
||||
# Handle different object types
|
||||
if (
|
||||
label == "pintu-kiri-buka"
|
||||
and not self.timer_active
|
||||
and not self.pintu_kiri_buka_detected
|
||||
and not self.timer_active_pintu_tutup
|
||||
):
|
||||
self.handle_pintu_kiri_buka(camera_name)
|
||||
elif (
|
||||
label == "pintu-kanan-buka"
|
||||
and not self.timer_active
|
||||
and not self.pintu_kanan_buka_detected
|
||||
and not self.timer_active_pintu_tutup
|
||||
):
|
||||
self.handle_pintu_kanan_buka(camera_name)
|
||||
elif label == "karung" and self.timer_active:
|
||||
if event_type != "end":
|
||||
return
|
||||
|
||||
path_data = (
|
||||
after_data.get("path_data")
|
||||
or before_data.get("path_data")
|
||||
or []
|
||||
)
|
||||
self.handle_karung(
|
||||
camera_name,
|
||||
track_id,
|
||||
path_data=path_data,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[END] Object ended - Camera: {camera_name}, Label: {label}, ID: {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:
|
||||
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 = {}
|
||||
|
||||
if not self.timer_active_pintu_tutup:
|
||||
self.start_timer_pintu_tutup()
|
||||
|
||||
# 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}")
|
||||
|
||||
# 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, path_data=None):
|
||||
"""Handle detection of karung object"""
|
||||
logger.info(f"Detected karung on {camera_name}")
|
||||
|
||||
# ======================= CALCULATE DIRECTION ==========================
|
||||
direction = self.calculate_direction_from_path(path_data) or "DOWN"
|
||||
|
||||
# Only count if timer is active
|
||||
if self.timer_active:
|
||||
with self.counter_lock:
|
||||
if direction == "DOWN":
|
||||
self.counter += 1
|
||||
elif direction == "UP":
|
||||
self.counter_out += 1
|
||||
|
||||
self.seen_objects[camera_name][track_id] = datetime.now()
|
||||
self.save_to_json(camera_name)
|
||||
self.publish_result()
|
||||
logger.info(
|
||||
f"Direction={direction}, Counter_IN={self.counter}, Counter_OUT={self.counter_out} 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_tutup(self):
|
||||
"""Start the 30-seconds timer Pintu"""
|
||||
logger.info("Starting 30-seconds timer Pintu Tutup")
|
||||
self.timer_active_pintu_tutup = True
|
||||
self.timer_start_time_pintu_tutup = datetime.now()
|
||||
|
||||
# Schedule timer expiration check
|
||||
timer_thread = threading.Thread(target=self.check_timer_expiration_pintu_tutup)
|
||||
timer_thread.daemon = True
|
||||
timer_thread.start()
|
||||
|
||||
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")
|
||||
# logger.info("Start Counting, Timer Counting 30-minutes 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_counting)
|
||||
# timer_thread.daemon = True
|
||||
# timer_thread.start()
|
||||
|
||||
# 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
|
||||
|
||||
# Call to RPI
|
||||
self.action_relay_on()
|
||||
|
||||
def check_timer_expiration_counting(self):
|
||||
"""Check if timer has expired (30 minutes)"""
|
||||
time.sleep(30 * 60) # Wait 5 minutes
|
||||
|
||||
if self.timer_active:
|
||||
logger.info("Stop Counting. Timer Counting Expired (30 minutes)")
|
||||
self.timer_active = False
|
||||
self.pintu_kiri_buka_detected = False
|
||||
self.pintu_kanan_buka_detected = False
|
||||
self.seen_objects = {}
|
||||
|
||||
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
|
||||
|
||||
# Call RPi
|
||||
# if not self.timer_active:
|
||||
# self.action_relay_off()
|
||||
|
||||
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 check_timer_expiration_pintu_tutup(self):
|
||||
"""Check if timer has expired (30 seconds)"""
|
||||
time.sleep(30) # Wait 5 minutes
|
||||
|
||||
if self.timer_active_pintu_tutup:
|
||||
logger.info("Timer Pintu Tutup expired (30 seconds)")
|
||||
self.timer_active_pintu_tutup = False
|
||||
|
||||
def publish_result(self):
|
||||
"""Publish counter result to MQTT topic"""
|
||||
if self.counter > 0:
|
||||
topic = f"{self.topic}/{self.camera_name}/karung"
|
||||
topic_out = f"{self.topic}/{self.camera_name}_out/karung"
|
||||
# TESTING
|
||||
# topic = f"{self.topic}/{self.camera_name}-check_pintu/karung"
|
||||
message = str(self.counter)
|
||||
message_out = str(self.counter_out)
|
||||
|
||||
try:
|
||||
self.report_client.publish(topic, message)
|
||||
logger.info(f"Published counter result to {topic}: {message}")
|
||||
|
||||
self.report_client.publish(topic_out, message_out)
|
||||
logger.info(f"Published counter result to {topic_out}: {message_out}")
|
||||
|
||||
# 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, business_date=None):
|
||||
"""Save counter result to SQLite database"""
|
||||
try:
|
||||
if business_date is None:
|
||||
settings = load_counter_settings()
|
||||
business_date = get_finished_business_date("masuk", settings)
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO karung_counts (camera_name, date, counter_value)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(self.camera_name, business_date.isoformat(), self.counter),
|
||||
)
|
||||
|
||||
camera_name_out = f"{self.camera_name}_out"
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO karung_counts (camera_name, date, counter_value)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(camera_name_out, business_date.isoformat(), self.counter_out),
|
||||
)
|
||||
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
|
||||
|
||||
camera_name_out = f"{camera_name}_out"
|
||||
if camera_name_out not in data:
|
||||
data[camera_name_out] = {}
|
||||
data[camera_name_out]["karung"] = self.counter_out
|
||||
|
||||
# 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)
|
||||
self.counter_out = self.load_from_json(f"{self.camera_name}_out")
|
||||
logger.info(
|
||||
f"Loaded previous counter_IN: {self.counter}, counter_out: {self.counter_out}"
|
||||
)
|
||||
self.save_to_json(self.camera_name)
|
||||
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 get_current_business_date(self):
|
||||
settings = load_counter_settings()
|
||||
local_now = get_local_now(settings)
|
||||
return get_business_date("masuk", settings, local_now)
|
||||
|
||||
def reset_counter_if_needed(self):
|
||||
current_business_date = self.get_current_business_date()
|
||||
|
||||
if current_business_date > self.active_business_date:
|
||||
self.reset_counter(self.active_business_date)
|
||||
elif current_business_date < self.active_business_date:
|
||||
logger.info(
|
||||
f"Masuk business date changed from {self.active_business_date} to {current_business_date}"
|
||||
)
|
||||
self.active_business_date = current_business_date
|
||||
|
||||
def reset_counter(self, business_date=None):
|
||||
"""Reset counter and clear detection flags"""
|
||||
if business_date is None:
|
||||
settings = load_counter_settings()
|
||||
business_date = get_finished_business_date("masuk", settings)
|
||||
|
||||
logger.info(f"Resetting counter for masuk business date {business_date}")
|
||||
# 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(business_date)
|
||||
self.counter = 0
|
||||
self.counter_out = 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 = {}
|
||||
self.active_business_date = self.get_current_business_date()
|
||||
|
||||
def run(self):
|
||||
"""Main run loop"""
|
||||
logger.info("Starting Frigate Counter Service")
|
||||
|
||||
try:
|
||||
while True:
|
||||
self.reset_counter_if_needed()
|
||||
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()
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Install script for Frigate MQTT Counter Service
|
||||
|
||||
echo "Installing Frigate MQTT Counter Service..."
|
||||
|
||||
# Create virtual environment if it doesn't exist
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies..."
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Create database file
|
||||
echo "Creating database..."
|
||||
python -c "import sqlite3; conn = sqlite3.connect('karung_counts.db'); conn.close(); print('Database created successfully')"
|
||||
|
||||
echo "Installation complete!"
|
||||
echo ""
|
||||
echo "To run the service:"
|
||||
echo " source venv/bin/activate"
|
||||
echo " python frigate_counter.py"
|
||||
echo ""
|
||||
echo "To test the service:"
|
||||
echo " source venv/bin/activate"
|
||||
echo " python test_frigate_counter.py"
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"kandang_1_karung_masuk": {
|
||||
"karung": 0
|
||||
},
|
||||
"kandang_1_karung_masuk_out": {
|
||||
"karung": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
paho-mqtt>=1.6.1
|
||||
schedule>=1.2.0
|
||||
flask>=2.0.0
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Start the Flask web application
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Check if virtual environment exists, if not create it
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies..."
|
||||
pip install -q -r requirements.txt
|
||||
|
||||
# Check if database exists
|
||||
if [ ! -f "karung_counts.db" ]; then
|
||||
echo "Error: Database karung_counts.db not found!"
|
||||
echo "Please run the frigate_counter.py first to initialize the database."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the Flask application
|
||||
echo "Starting Flask web application..."
|
||||
echo "Access the dashboard at: http://localhost:5000"
|
||||
python web_app.py
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test script to verify persistence improvements without MQTT connections
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import date
|
||||
|
||||
# Add the current directory to Python path to import our module
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def test_persistence_improvements():
|
||||
"""Test that the persistence improvements work correctly"""
|
||||
print("Testing persistence improvements...")
|
||||
|
||||
# Create a temporary directory to avoid conflicts
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Change to temp directory
|
||||
original_dir = os.getcwd()
|
||||
os.chdir(temp_dir)
|
||||
|
||||
try:
|
||||
from frigate_counter import FrigateCounter
|
||||
|
||||
# Test 1: Check that we can create counter instance without MQTT connection
|
||||
print("1. Testing counter instantiation...")
|
||||
# Mock the MQTT connection to avoid errors during testing
|
||||
counter = FrigateCounter()
|
||||
print("✓ Counter instantiated successfully")
|
||||
|
||||
# Test 2: Test that counter value can be loaded from JSON
|
||||
print("2. Testing JSON storage functionality...")
|
||||
# Create a test JSON file with some data
|
||||
test_data = {"frigate_camera": {"karung": 42}}
|
||||
with open('karung_counters.json', 'w') as f:
|
||||
json.dump(test_data, f)
|
||||
|
||||
# Create a new counter instance to test loading
|
||||
counter2 = FrigateCounter()
|
||||
print(f"✓ Counter loaded from JSON: {counter2.counter}")
|
||||
assert counter2.counter == 42, f"Expected 42, got {counter2.counter}"
|
||||
|
||||
# Test 3: Test save to JSON functionality
|
||||
print("3. Testing save to JSON functionality...")
|
||||
counter2.counter = 100
|
||||
counter2.save_to_json('test_camera')
|
||||
|
||||
# Verify the data was saved
|
||||
with open('karung_counters.json', 'r') as f:
|
||||
data = json.load(f)
|
||||
assert data['test_camera']['karung'] == 100, "Save to JSON failed"
|
||||
print("✓ Save to JSON works correctly")
|
||||
|
||||
# Test 4: Test database functionality
|
||||
print("4. Testing database functionality...")
|
||||
counter2.save_to_database()
|
||||
print("✓ Database save works correctly")
|
||||
|
||||
# Test 5: Test reset_counter functionality
|
||||
print("5. Testing reset_counter functionality...")
|
||||
counter2.counter = 50
|
||||
counter2.reset_counter()
|
||||
print("✓ Reset counter works correctly")
|
||||
|
||||
print("\n✓ All persistence improvements tests passed!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error in persistence test: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_persistence_improvements()
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Frigate MQTT Counter Service
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Add the current directory to Python path to import our module
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def test_database_creation():
|
||||
"""Test that database is created properly"""
|
||||
print("Testing database creation...")
|
||||
|
||||
# Create a temporary directory to test database creation
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Change to temp directory to avoid conflicts
|
||||
original_dir = os.getcwd()
|
||||
os.chdir(temp_dir)
|
||||
|
||||
try:
|
||||
# Import after changing directory to avoid conflicts
|
||||
from frigate_counter import FrigateCounter
|
||||
|
||||
# Create a temporary counter instance to test database
|
||||
counter = FrigateCounter()
|
||||
|
||||
# Check if database file exists
|
||||
if os.path.exists(counter.db_path):
|
||||
print("✓ Database file created successfully")
|
||||
|
||||
# Check table structure
|
||||
conn = sqlite3.connect(counter.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(karung_counts)")
|
||||
columns = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
expected_columns = ['id', 'camera_name', 'date', 'counter_value', 'timestamp']
|
||||
actual_columns = [col[1] for col in columns]
|
||||
|
||||
if set(expected_columns) <= set(actual_columns):
|
||||
print("✓ Database table structure is correct")
|
||||
else:
|
||||
print("✗ Database table structure is incorrect")
|
||||
print(f"Expected: {expected_columns}")
|
||||
print(f"Actual: {actual_columns}")
|
||||
else:
|
||||
print("✗ Database file was not created")
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
|
||||
def test_counter_logic():
|
||||
"""Test counter logic"""
|
||||
print("\nTesting counter logic...")
|
||||
|
||||
# For now, just verify the class can be instantiated
|
||||
# without actually connecting to MQTT brokers
|
||||
try:
|
||||
# Mock the MQTT connection to avoid errors during testing
|
||||
import frigate_counter
|
||||
|
||||
# Create a simple test that doesn't initialize MQTT connections
|
||||
class TestCounter(frigate_counter.FrigateCounter):
|
||||
def __init__(self):
|
||||
# Initialize with minimal setup
|
||||
self.db_path = 'test_karung_counts.db'
|
||||
self.init_database()
|
||||
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.topic = 'test'
|
||||
self.site_name = 'test'
|
||||
|
||||
test_counter = TestCounter()
|
||||
print("✓ FrigateCounter class can be instantiated successfully")
|
||||
print(f"✓ Default topic: {test_counter.topic}")
|
||||
print(f"✓ Default site name: {test_counter.site_name}")
|
||||
print(f"✓ Database path: {test_counter.db_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error in counter logic test: {e}")
|
||||
|
||||
def test_environment_variables():
|
||||
"""Test environment variable handling"""
|
||||
print("\nTesting environment variables...")
|
||||
|
||||
# Set some test values
|
||||
os.environ['FRIGATE_MQTT_HOST'] = 'test-frigate-host'
|
||||
os.environ['REPORT_MQTT_HOST'] = 'test-report-host'
|
||||
os.environ['TOPIC'] = 'test-topic'
|
||||
os.environ['SITE_NAME'] = 'test-site'
|
||||
|
||||
# Import after setting environment variables
|
||||
from frigate_counter import FrigateCounter
|
||||
counter = FrigateCounter()
|
||||
|
||||
print(f"✓ FRIGATE_MQTT_HOST: {counter.frigate_mqtt_host}")
|
||||
print(f"✓ REPORT_MQTT_HOST: {counter.report_mqtt_host}")
|
||||
print(f"✓ TOPIC: {counter.topic}")
|
||||
print(f"✓ SITE_NAME: {counter.site_name}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running Frigate Counter Service Tests\n")
|
||||
|
||||
test_database_creation()
|
||||
test_counter_logic()
|
||||
test_environment_variables()
|
||||
|
||||
print("\nTest completed.")
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for JSON storage functionality in Frigate MQTT Counter Service
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import json
|
||||
from datetime import datetime
|
||||
import threading
|
||||
|
||||
# Add the current directory to Python path to import our module
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def test_json_storage():
|
||||
"""Test that JSON storage works correctly"""
|
||||
print("Testing JSON storage functionality...")
|
||||
|
||||
# Create a temporary directory to test
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Change to temp directory to avoid conflicts
|
||||
original_dir = os.getcwd()
|
||||
os.chdir(temp_dir)
|
||||
|
||||
try:
|
||||
from frigate_counter import FrigateCounter
|
||||
|
||||
# Create a counter instance
|
||||
counter = FrigateCounter()
|
||||
|
||||
# Test JSON storage initialization
|
||||
if os.path.exists(counter.json_storage_path):
|
||||
print("✓ JSON storage file created successfully")
|
||||
|
||||
# Test writing to JSON storage
|
||||
counter.counter = 5
|
||||
counter.save_to_json('test_camera')
|
||||
|
||||
# Test reading from JSON storage
|
||||
loaded_counter = counter.load_from_json('test_camera')
|
||||
if loaded_counter == 5:
|
||||
print("✓ JSON storage read/write works correctly")
|
||||
else:
|
||||
print(f"✗ JSON storage read failed: expected 5, got {loaded_counter}")
|
||||
|
||||
# Test multiple cameras
|
||||
counter.counter = 10
|
||||
counter.save_to_json('camera_1')
|
||||
|
||||
counter.counter = 15
|
||||
counter.save_to_json('camera_2')
|
||||
|
||||
loaded_1 = counter.load_from_json('camera_1')
|
||||
loaded_2 = counter.load_from_json('camera_2')
|
||||
|
||||
if loaded_1 == 10 and loaded_2 == 15:
|
||||
print("✓ Multiple camera JSON storage works correctly")
|
||||
else:
|
||||
print(f"✗ Multiple camera JSON storage failed: {loaded_1}, {loaded_2}")
|
||||
|
||||
# Test reading non-existent camera
|
||||
non_existent = counter.load_from_json('non_existent_camera')
|
||||
if non_existent == 0:
|
||||
print("✓ Reading non-existent camera returns 0 correctly")
|
||||
else:
|
||||
print(f"✗ Reading non-existent camera failed: {non_existent}")
|
||||
|
||||
else:
|
||||
print("✗ JSON storage file was not created")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error in JSON storage test: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running JSON Storage Tests\n")
|
||||
test_json_storage()
|
||||
print("\nTest completed.")
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive test for persistence improvements
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import date
|
||||
|
||||
# Add the current directory to Python path to import our module
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def test_startup_loading():
|
||||
"""Test that counter values are loaded on startup"""
|
||||
print("Testing startup loading...")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
original_dir = os.getcwd()
|
||||
os.chdir(temp_dir)
|
||||
|
||||
try:
|
||||
# Test the core persistence functionality by directly testing methods
|
||||
from frigate_counter import FrigateCounter
|
||||
|
||||
# Create a test JSON file with some data
|
||||
test_data = {"frigate_camera": {"karung": 123}}
|
||||
with open('karung_counters.json', 'w') as f:
|
||||
json.dump(test_data, f)
|
||||
|
||||
# Test the load_from_json method directly
|
||||
counter = FrigateCounter()
|
||||
loaded_value = counter.load_from_json('frigate_camera')
|
||||
assert loaded_value == 123, f"Expected 123, got {loaded_value}"
|
||||
print("✓ Counter value loaded correctly from JSON")
|
||||
|
||||
# Test the load_previous_counter method
|
||||
counter2 = FrigateCounter()
|
||||
# We can't easily test the full load_previous_counter without MQTT,
|
||||
# but we can verify the method exists and works
|
||||
print("✓ load_previous_counter method exists")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Startup loading test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
|
||||
return True
|
||||
|
||||
def test_save_functionality():
|
||||
"""Test that save functionality works correctly"""
|
||||
print("Testing save functionality...")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
original_dir = os.getcwd()
|
||||
os.chdir(temp_dir)
|
||||
|
||||
try:
|
||||
from frigate_counter import FrigateCounter
|
||||
|
||||
# Test save to JSON
|
||||
counter = FrigateCounter()
|
||||
counter.counter = 42
|
||||
counter.save_to_json('test_camera')
|
||||
|
||||
# Verify data was saved
|
||||
with open('karung_counters.json', 'r') as f:
|
||||
data = json.load(f)
|
||||
assert data['test_camera']['karung'] == 42, "Save to JSON failed"
|
||||
print("✓ Save to JSON works correctly")
|
||||
|
||||
# Test save to database
|
||||
counter.save_to_database()
|
||||
print("✓ Save to database works correctly")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Save functionality test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
|
||||
return True
|
||||
|
||||
def test_error_handling():
|
||||
"""Test that error handling works properly"""
|
||||
print("Testing error handling...")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
original_dir = os.getcwd()
|
||||
os.chdir(temp_dir)
|
||||
|
||||
try:
|
||||
from frigate_counter import FrigateCounter
|
||||
|
||||
# Test that methods don't crash with corrupted data
|
||||
counter = FrigateCounter()
|
||||
|
||||
# Create a corrupted JSON file
|
||||
with open('karung_counters.json', 'w') as f:
|
||||
f.write('{"invalid": json}')
|
||||
|
||||
# These should not crash the application
|
||||
result = counter.load_from_json('test_camera')
|
||||
assert result == 0, f"Expected 0, got {result}"
|
||||
print("✓ Error handling works correctly")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error handling test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running comprehensive persistence tests\n")
|
||||
|
||||
success = True
|
||||
success &= test_startup_loading()
|
||||
success &= test_save_functionality()
|
||||
success &= test_error_handling()
|
||||
|
||||
if success:
|
||||
print("\n✓ All persistence tests passed!")
|
||||
else:
|
||||
print("\n✗ Some tests failed!")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,43 @@
|
||||
# Usage Examples
|
||||
|
||||
## Running with Default Settings
|
||||
|
||||
```bash
|
||||
python frigate_counter.py
|
||||
```
|
||||
|
||||
## Running with Custom MQTT Settings
|
||||
|
||||
```bash
|
||||
FRIGATE_MQTT_HOST=192.168.1.100 \
|
||||
FRIGATE_MQTT_PORT=1883 \
|
||||
REPORT_MQTT_HOST=192.168.1.101 \
|
||||
REPORT_MQTT_PORT=1883 \
|
||||
TOPIC=frigate/events \
|
||||
SITE_NAME=warehouse-1 \
|
||||
python frigate_counter.py
|
||||
```
|
||||
|
||||
## Running with Environment File
|
||||
|
||||
```bash
|
||||
# Create environment file
|
||||
cat > .env << EOF
|
||||
FRIGATE_MQTT_HOST=192.168.1.100
|
||||
FRIGATE_MQTT_PORT=1883
|
||||
REPORT_MQTT_HOST=192.168.1.101
|
||||
REPORT_MQTT_PORT=1883
|
||||
TOPIC=frigate/events
|
||||
SITE_NAME=warehouse-1
|
||||
EOF
|
||||
|
||||
# Source and run
|
||||
source .env && python frigate_counter.py
|
||||
```
|
||||
|
||||
## Testing the Installation
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
python test_frigate_counter.py
|
||||
```
|
||||
+221
@@ -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=8080, debug=True)
|
||||
Reference in new issue
Block a user