Files
karung-counting-feedmill-se…/counter_dashboard.py
T
andrew 2d9c66cbb6
ci / smoke (push) Canceled after 0s
feat: unified config.yaml with extensible model presets (A-D data-driven, E/F ready)
- config.yaml canonical for stream/models/counting/batch/output/camera
- models.modes hold engines+class filters only; conf/iou/min_bbox in detection_params
- predict.py derives tracker roles structurally (no per-mode branching)
- dashboard mode switch validates + persists atomically to config.yaml
- .env keeps secrets/deployment only; zones.json geometry; tracker.yaml hyperparams
- batch_mode.json keeps manual/auto batch mode; legacy model_mode ignored w/ warning
- src/detection+tracking gain iou param (default 0.7 = no behavior change)
2026-09-17 11:44:45 +07:00

958 lines
34 KiB
Python

#!/usr/bin/env python3
"""
Edge Jetson production counter dashboard.
Reads jetson_counter.db + current_batch.json from jetson-counter stack.
Default port 5000 (replaces frigate-counter dashboard role).
"""
import json
import os
import sqlite3
import time
from io import BytesIO
from datetime import datetime, timedelta
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from flask import Flask, render_template, jsonify, request, Response
from werkzeug.serving import WSGIRequestHandler
from dotenv import load_dotenv
load_dotenv()
# Unified config (config.yaml canonical; .env supplies secrets/deployment-only
# values: RTSP_URL, dashboard host/ports/secret/site). Env vars still override
# file paths when explicitly set (backward compatible with old deployments).
from src.config_loader import load_config, set_active_mode
_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
CFG = load_config(_CONFIG_PATH)
def _env_or(cfg_value, *env_keys):
"""Backward compat: explicit env var wins over config.yaml for file paths."""
for k in env_keys:
v = os.getenv(k)
if v:
return v
return cfg_value
app = Flask(__name__, template_folder="templates")
app.config["SECRET_KEY"] = CFG.dashboard.secret_key
if os.name == "nt":
_DEFAULT_DIR = "d:/Belajar/menghitung karung"
DB_PATH = f"{_DEFAULT_DIR}/jetson_counter.db"
CURRENT_BATCH_PATH = f"{_DEFAULT_DIR}/current_batch.json"
BATCH_MODE_PATH = f"{_DEFAULT_DIR}/batch_mode.json"
LIVE_STREAM_FRAME_PATH = f"{_DEFAULT_DIR}/live_frame.jpg"
LIVE_STATUS_FILE = f"{_DEFAULT_DIR}/live_status.json"
else:
_DEFAULT_DIR = CFG.output.dir
DB_PATH = _env_or(os.path.join(_DEFAULT_DIR, CFG.output.db_name), "DB_PATH")
CURRENT_BATCH_PATH = _env_or(
os.path.join(_DEFAULT_DIR, CFG.output.state_file),
"STATE_FILE", "CURRENT_BATCH_PATH")
BATCH_MODE_PATH = _env_or(
os.path.join(_DEFAULT_DIR, CFG.output.batch_mode_file), "BATCH_MODE_FILE")
LIVE_STREAM_FRAME_PATH = _env_or(CFG.output.live_frame_path, "LIVE_STREAM_FRAME_PATH")
LIVE_STATUS_FILE = _env_or(CFG.output.live_status_path, "LIVE_STATUS_FILE")
CUTOFF_TIME = _env_or(CFG.batch.daily_cutoff_time, "CUTOFF_TIME", "DAILY_CUTOFF_TIME")
SITE_NAME = CFG.dashboard.site_name
DASHBOARD_PORT = CFG.dashboard.port
DASHBOARD_HOST = CFG.dashboard.host
FLASK_DEBUG = CFG.dashboard.debug
@app.route("/api/live-video")
def api_live_video():
if not os.path.isfile(LIVE_STREAM_FRAME_PATH):
return jsonify({"success": False, "error": "Live stream frame not available yet"}), 503
def generate():
consecutive_fails = 0
MAX_FAILS = 30
while True:
try:
with open(LIVE_STREAM_FRAME_PATH, "rb") as f:
jpeg = f.read()
consecutive_fails = 0
yield (b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n")
except FileNotFoundError:
consecutive_fails += 1
if consecutive_fails >= MAX_FAILS:
return
time.sleep(1.0)
continue
except Exception:
consecutive_fails += 1
if consecutive_fails >= MAX_FAILS:
return
time.sleep(0.5)
continue
time.sleep(0.05)
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
def _ensure_db():
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
counting_date TEXT NOT NULL,
batch_number INTEGER NOT NULL,
camera_name TEXT NOT NULL,
object_label TEXT NOT NULL,
count INTEGER NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(counting_date, batch_number, camera_name, object_label)
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS daily_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
counting_date TEXT NOT NULL,
camera_name TEXT NOT NULL,
object_label TEXT NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0,
total_batches INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(counting_date, camera_name, object_label)
)
"""
)
# Additive migration for box counting + model mode (idempotent).
cur.execute("PRAGMA table_info(batches)")
_cols = {r[1] for r in cur.fetchall()}
for _col, _typ in (
("box_loading", "INTEGER NOT NULL DEFAULT 0"),
("box_unloading", "INTEGER NOT NULL DEFAULT 0"),
("model_mode", "TEXT NOT NULL DEFAULT 'A'"),
):
if _col not in _cols:
cur.execute(f"ALTER TABLE batches ADD COLUMN {_col} {_typ}")
conn.commit()
conn.close()
_ensure_db()
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def _batches_box_select():
"""Box/model select exprs — real columns when migrated, literals otherwise."""
try:
conn = get_db()
cur = conn.cursor()
cur.execute("PRAGMA table_info(batches)")
cols = {r[1] for r in cur.fetchall()}
conn.close()
except Exception:
cols = set()
if {"box_loading", "box_unloading", "model_mode"} <= cols:
return "box_loading, box_unloading, model_mode"
return "0 AS box_loading, 0 AS box_unloading, 'A' AS model_mode"
def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
if dt is None:
dt = datetime.now()
cutoff = datetime.strptime(cutoff_str, "%H:%M").time()
if cutoff.hour == 0 and cutoff.minute == 0:
return dt.date().isoformat()
if dt.time() < cutoff:
return (dt.date() - timedelta(days=1)).isoformat()
return dt.date().isoformat()
CAMERA_NAME = _env_or(CFG.camera.name, "CAMERA_NAME")
OBJECT_LABEL = _env_or(CFG.camera.object_label, "OBJECT_LABEL")
OFFICE_PORT = CFG.dashboard.office_port
def is_office_request():
"""Check if request comes from office port."""
server_port = request.environ.get("SERVER_PORT", str(DASHBOARD_PORT))
# Also check Host header if port is in Host (e.g. 192.168.192.96:5721)
host_header = request.headers.get("Host", "")
if f":{OFFICE_PORT}" in host_header or str(server_port) == str(OFFICE_PORT):
return True
return False
@app.route("/")
@app.route("/monitoring")
def index():
if is_office_request():
return render_template("monitoring.html", site_name=SITE_NAME, show_nav=True)
# Port 5000 (Operator)
return render_template("operator.html", site_name=SITE_NAME, show_nav=False)
@app.route("/operator")
def operator_page():
return render_template("operator.html", site_name=SITE_NAME, show_nav=False)
@app.route("/history")
def history_page():
if not is_office_request():
return render_template("operator.html", site_name=SITE_NAME, show_nav=False)
return render_template("history.html", site_name=SITE_NAME, show_nav=True)
@app.route("/analytics")
def analytics_page():
if not is_office_request():
return render_template("operator.html", site_name=SITE_NAME, show_nav=False)
return render_template("analytics.html", site_name=SITE_NAME, show_nav=True)
def get_next_batch_number(counting_date):
try:
conn = get_db()
cur = conn.cursor()
cur.execute(
"""
SELECT COALESCE(MAX(batch_number), 0)
FROM batches
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
""",
(counting_date, CAMERA_NAME, OBJECT_LABEL),
)
row = cur.fetchone()
conn.close()
return row[0] + 1
except Exception as e:
print(f"[DB Error] Gagal mendapatkan batch_number: {e}")
return 1
@app.route("/api/batch/start", methods=["POST"])
def api_batch_start():
try:
# Check if already active
if os.path.exists(CURRENT_BATCH_PATH):
try:
with open(CURRENT_BATCH_PATH, "r") as f:
curr = json.load(f)
if curr and curr.get("batch_number"):
return jsonify({
"success": True,
"message": "Batch already active",
"batch_number": curr.get("batch_number"),
"counting_date": curr.get("counting_date"),
"start_time": curr.get("start_time")
})
except Exception:
pass
counting_date = get_counting_date()
batch_num = get_next_batch_number(counting_date)
now_iso = datetime.now().isoformat()
batch_state = {
"counting_date": counting_date,
"batch_number": batch_num,
"count": 0,
"box_count": 0,
"box_unloading": 0,
"model_mode": _read_batch_mode_file()["model_mode"],
"start_time": now_iso,
"last_detection_time": now_iso,
"manual_control": True
}
os.makedirs(os.path.dirname(CURRENT_BATCH_PATH), exist_ok=True)
tmp_file = f"{CURRENT_BATCH_PATH}.tmp"
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(batch_state, f, indent=2)
os.replace(tmp_file, CURRENT_BATCH_PATH)
return jsonify({
"success": True,
"message": f"Batch #{batch_num} started",
"batch_number": batch_num,
"counting_date": counting_date,
"start_time": now_iso
})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/batch/stop", methods=["POST"])
def api_batch_stop():
try:
if not os.path.exists(CURRENT_BATCH_PATH):
return jsonify({"success": False, "error": "No active batch to stop"}), 400
with open(CURRENT_BATCH_PATH, "r") as f:
curr = json.load(f)
if not curr or not curr.get("batch_number"):
return jsonify({"success": False, "error": "No active batch data"}), 400
counting_date = curr["counting_date"]
batch_num = curr["batch_number"]
final_count = curr.get("count", 0)
box_final = curr.get("box_count", 0)
box_unloading = curr.get("box_unloading", 0)
model_mode = curr.get("model_mode", _read_batch_mode_file()["model_mode"])
start_time_iso = curr.get("start_time", datetime.now().isoformat())
end_time_iso = datetime.now().isoformat()
if final_count > 0:
conn = get_db()
cur = conn.cursor()
cur.execute(
"""
INSERT OR REPLACE INTO batches
(counting_date, batch_number, camera_name, object_label, count, start_time, end_time)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_iso, end_time_iso),
)
try:
cur.execute(
"""
UPDATE batches SET box_loading = ?, box_unloading = ?, model_mode = ?
WHERE counting_date = ? AND batch_number = ? AND camera_name = ? AND object_label = ?
""",
(box_final, box_unloading, model_mode,
counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL),
)
except Exception:
pass
cur.execute(
"""
SELECT SUM(count), COUNT(id)
FROM batches
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
""",
(counting_date, CAMERA_NAME, OBJECT_LABEL),
)
sum_row = cur.fetchone()
tot_count = sum_row[0] if sum_row[0] is not None else 0
tot_batches = sum_row[1] if sum_row[1] is not None else 0
cur.execute(
"""
INSERT OR REPLACE INTO daily_summaries
(counting_date, camera_name, object_label, total_count, total_batches, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""",
(counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches),
)
conn.commit()
conn.close()
# Remove active batch state
try:
os.remove(CURRENT_BATCH_PATH)
except Exception:
pass
return jsonify({
"success": True,
"message": f"Batch #{batch_num} stopped",
"batch_number": batch_num,
"final_count": final_count,
"box_final_count": box_final,
"start_time": start_time_iso,
"end_time": end_time_iso
})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# Model modes are DATA in config.yaml models.modes — derived here so future
# modes (E, F, ...) appear automatically. batch_mode.json keeps ONLY the
# manual/auto batch "mode"; model_mode lives in config.yaml now.
MODEL_MODE_CHOICES = tuple(CFG.models.modes.keys())
MODEL_MODE_DESCRIPTIONS = {
m: preset.description for m, preset in CFG.models.modes.items()
}
def _active_model_mode() -> str:
"""Canonical model mode: config.yaml models.active_mode (validated)."""
try:
return load_config(_CONFIG_PATH).models.active_mode
except Exception:
return CFG.models.active_mode
def _read_batch_mode_file():
data = {"mode": "manual", "model_mode": _active_model_mode()}
if os.path.exists(BATCH_MODE_PATH):
try:
with open(BATCH_MODE_PATH, "r") as f:
stored = json.load(f)
data["mode"] = stored.get("mode", "manual")
# Legacy model_mode in batch_mode.json is IGNORED (config.yaml wins).
legacy = (stored.get("model_mode") or "").upper()
if legacy and legacy != data["model_mode"]:
print(f"[WARN] batch_mode.json model_mode={legacy!r} diabaikan — "
f"config.yaml active_mode={data['model_mode']!r} yang berlaku.")
except Exception:
pass
return data
@app.route("/api/batch/mode", methods=["GET", "POST"])
def api_batch_mode():
if request.method == "POST":
try:
req_data = request.get_json(silent=True) or request.form
stored = _read_batch_mode_file()
if "mode" in req_data:
mode = str(req_data.get("mode", "manual")).lower()
if mode not in ["auto", "manual"]:
return jsonify({"success": False, "error": "Invalid mode. Use 'auto' or 'manual'"}), 400
stored["mode"] = mode
if "model_mode" in req_data:
mmode = str(req_data.get("model_mode", "")).upper()
try:
# Validates against config.yaml models.modes, then persists
# atomically (tmp+replace, comments preserved). Manual
# `karung-counter` restart still required to apply.
set_active_mode(_CONFIG_PATH, mmode)
except (ValueError, RuntimeError) as ve:
return jsonify({
"success": False,
"error": f"Invalid model_mode. Use one of {sorted(MODEL_MODE_CHOICES)}: {ve}",
}), 400
stored["model_mode"] = mmode
stored["updated_at"] = datetime.now().isoformat()
# batch_mode.json keeps ONLY the manual/auto batch mode now.
batch_state = {"mode": stored["mode"], "updated_at": stored["updated_at"]}
os.makedirs(os.path.dirname(BATCH_MODE_PATH) or ".", exist_ok=True)
tmp_path = BATCH_MODE_PATH + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(batch_state, f, indent=2)
os.replace(tmp_path, BATCH_MODE_PATH)
return jsonify({"success": True, "mode": stored["mode"],
"model_mode": stored["model_mode"],
"message": f"Batch mode={stored['mode']}, model_mode={stored['model_mode']} "
f"(model_mode applies on next karung-counter restart)"})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# GET method
stored = _read_batch_mode_file()
return jsonify({"success": True, "mode": stored["mode"],
"model_mode": stored["model_mode"]})
@app.route("/api/model-modes")
def api_model_modes():
stored = _read_batch_mode_file()
return jsonify({
"success": True,
"active": stored["model_mode"],
"modes": [{"id": m, "description": d}
for m, d in MODEL_MODE_DESCRIPTIONS.items()],
})
@app.route("/api/current-batch")
def api_current_batch():
fps_val = 0.0
status_file = LIVE_STATUS_FILE
try:
if os.path.exists(status_file):
with open(status_file, "r") as sf:
sdata = json.load(sf)
fps_val = sdata.get("fps", 0.0)
except Exception:
pass
try:
with open(CURRENT_BATCH_PATH, "r") as f:
data = json.load(f)
stored = _read_batch_mode_file()
return jsonify(
{
"success": True,
"counting_date": data.get("counting_date"),
"batch_number": data.get("batch_number"),
"count": data.get("count", 0),
"box_count": data.get("box_count", 0),
"box_unloading": data.get("box_unloading", 0),
"model_mode": data.get("model_mode", stored["model_mode"]),
"mode": stored["mode"],
"start_time": data.get("start_time"),
"last_detection_time": data.get("last_detection_time"),
"fps": fps_val
}
)
except FileNotFoundError:
stored = _read_batch_mode_file()
return jsonify(
{
"success": False,
"error": "No active batch",
"count": 0,
"box_count": 0,
"model_mode": stored["model_mode"],
"mode": stored["mode"],
"batch_number": None,
"counting_date": None,
"fps": fps_val
}
), 200
except Exception as e:
return jsonify(
{
"success": False,
"error": str(e),
"count": 0,
"box_count": 0,
"batch_number": None,
"counting_date": None,
"fps": fps_val
}
), 500
@app.route("/api/previous-batch")
def api_previous_batch():
try:
conn = get_db()
cur = conn.cursor()
cur.execute(
f"""
SELECT counting_date, batch_number, count, start_time, end_time,
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes,
{_batches_box_select()}
FROM batches
ORDER BY end_time DESC
LIMIT 1
"""
)
row = cur.fetchone()
conn.close()
if row:
return jsonify(
{
"success": True,
"date": row["counting_date"],
"batch_number": row["batch_number"],
"count": row["count"],
"box_loading": row["box_loading"],
"model_mode": row["model_mode"],
"start_time": row["start_time"],
"end_time": row["end_time"],
"duration_minutes": row["duration_minutes"],
}
)
return jsonify({"success": False, "error": "No previous batch"}), 200
except sqlite3.OperationalError as e:
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/summary")
def api_summary():
try:
conn = get_db()
cur = conn.cursor()
today = get_counting_date()
cur.execute(
"""
SELECT COALESCE(total_count, 0) as total_count,
COALESCE(total_batches, 0) as total_batches
FROM daily_summaries
WHERE counting_date = ?
""",
(today,),
)
today_row = cur.fetchone()
yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat()
cur.execute(
"""
SELECT COALESCE(total_count, 0) as total_count,
COALESCE(total_batches, 0) as total_batches
FROM daily_summaries
WHERE counting_date = ?
""",
(yesterday,),
)
yesterday_row = cur.fetchone()
cur.execute(
"""
SELECT COALESCE(SUM(total_count), 0) as grand_total,
COALESCE(SUM(total_batches), 0) as grand_batches,
COUNT(DISTINCT counting_date) as total_days
FROM daily_summaries
"""
)
all_time = cur.fetchone()
cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_summaries")
avg = cur.fetchone()
cur.execute(
"""
SELECT counting_date, total_count
FROM daily_summaries
ORDER BY total_count DESC
LIMIT 1
"""
)
best = cur.fetchone()
conn.close()
return jsonify(
{
"today": {
"date": today,
"total_count": today_row["total_count"] if today_row else 0,
"total_batches": today_row["total_batches"] if today_row else 0,
},
"yesterday": {
"date": yesterday,
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
"total_batches": yesterday_row["total_batches"] if yesterday_row else 0,
},
"all_time": {
"grand_total": all_time["grand_total"],
"grand_batches": all_time["grand_batches"],
"total_days": all_time["total_days"],
},
"average_per_day": avg["avg_per_day"] or 0,
"best_day": {
"date": best["counting_date"] if best else None,
"count": best["total_count"] if best else 0,
},
}
)
except sqlite3.OperationalError as e:
return jsonify({"success": False, "error": f"Database unavailable: {e}", "today": {"date": datetime.now().date().isoformat(), "total_count": 0, "total_batches": 0}, "yesterday": {"date": "", "total_count": 0, "total_batches": 0}, "all_time": {"grand_total": 0, "grand_batches": 0, "total_days": 0}, "average_per_day": 0, "best_day": {"date": None, "count": 0}}), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/daily-data")
def api_daily_data():
try:
days = request.args.get("days", 30, type=int)
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
conn = get_db()
cur = conn.cursor()
cur.execute(
"""
SELECT counting_date, total_count, total_batches,
ROUND(CAST(total_count AS FLOAT) / total_batches, 1) as avg_per_batch
FROM daily_summaries
WHERE counting_date >= ?
ORDER BY counting_date ASC
""",
(date_from,),
)
daily_data = [
{
"date": row["counting_date"],
"total_count": row["total_count"],
"total_batches": row["total_batches"],
"avg_per_batch": row["avg_per_batch"] or 0,
}
for row in cur.fetchall()
]
conn.close()
return jsonify(daily_data)
except sqlite3.OperationalError as e:
return jsonify([]), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/day-detail/<date>")
def api_day_detail(date):
try:
conn = get_db()
cur = conn.cursor()
cur.execute(
f"""
SELECT batch_number, count, start_time, end_time,
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes,
{_batches_box_select()}
FROM batches
WHERE counting_date = ?
ORDER BY batch_number ASC
""",
(date,),
)
batches = []
total_duration = 0
total_boxes = 0
for row in cur.fetchall():
duration = row["duration_minutes"] or 0
total_duration += duration
total_boxes += row["box_loading"] or 0
batches.append(
{
"batch_number": row["batch_number"],
"count": row["count"],
"box_loading": row["box_loading"],
"model_mode": row["model_mode"],
"start_time": row["start_time"],
"end_time": row["end_time"],
"duration_minutes": duration,
}
)
cur.execute(
"""
SELECT total_count, total_batches
FROM daily_summaries
WHERE counting_date = ?
""",
(date,),
)
summary = cur.fetchone()
conn.close()
return jsonify(
{
"date": date,
"total_count": summary["total_count"] if summary else 0,
"total_batches": summary["total_batches"] if summary else 0,
"total_boxes": total_boxes,
"total_duration_minutes": round(total_duration, 1),
"avg_duration_minutes": round(total_duration / len(batches), 1) if batches else 0,
"batches": batches,
}
)
except sqlite3.OperationalError as e:
return jsonify({"date": date, "total_count": 0, "total_batches": 0, "total_duration_minutes": 0, "avg_duration_minutes": 0, "batches": [], "error": f"Database unavailable: {e}"}), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/recent-batches")
def api_recent_batches():
try:
limit = request.args.get("limit", 10, type=int)
conn = get_db()
cur = conn.cursor()
cur.execute(
f"""
SELECT counting_date, batch_number, count, start_time, end_time,
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes,
{_batches_box_select()}
FROM batches
ORDER BY end_time DESC
LIMIT ?
""",
(limit,),
)
batches = [
{
"date": row["counting_date"],
"batch_number": row["batch_number"],
"count": row["count"],
"box_loading": row["box_loading"],
"model_mode": row["model_mode"],
"start_time": row["start_time"],
"end_time": row["end_time"],
"duration_minutes": row["duration_minutes"] or 0,
}
for row in cur.fetchall()
]
conn.close()
return jsonify(batches)
except sqlite3.OperationalError as e:
return jsonify([]), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/available-dates")
def api_available_dates():
try:
conn = get_db()
cur = conn.cursor()
cur.execute(
"""
SELECT counting_date, total_count, total_batches
FROM daily_summaries
ORDER BY counting_date DESC
"""
)
dates = [
{
"date": row["counting_date"],
"total_count": row["total_count"],
"total_batches": row["total_batches"],
}
for row in cur.fetchall()
]
conn.close()
return jsonify(dates)
except sqlite3.OperationalError as e:
return jsonify([]), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
def _excel_response(wb, filename):
output = BytesIO()
wb.save(output)
output.seek(0)
return Response(
output.getvalue(),
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
def _style_header(ws, cols):
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid")
thin_border = Border(
left=Side(style="thin"), right=Side(style="thin"),
top=Side(style="thin"), bottom=Side(style="thin"),
)
for col_idx, (col_letter, text) in enumerate(cols, 1):
cell = ws.cell(row=1, column=col_idx, value=text)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
cell.border = thin_border
ws.freeze_panes = "A2"
def _auto_width(ws):
for col in ws.columns:
max_len = 0
col_letter = col[0].column_letter
for cell in col:
if cell.value is not None:
max_len = max(max_len, len(str(cell.value)))
ws.column_dimensions[col_letter].width = max_len + 4
@app.route("/api/export-daily-csv")
def export_daily_xlsx():
try:
days = request.args.get("days", 30, type=int)
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
conn = get_db()
cur = conn.cursor()
cur.execute(
"""
SELECT counting_date, batch_number, count, start_time, end_time,
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
FROM batches
WHERE counting_date >= ?
ORDER BY counting_date ASC, batch_number ASC
""",
(date_from,),
)
rows = cur.fetchall()
conn.close()
except sqlite3.OperationalError as e:
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
wb = Workbook()
ws = wb.active
ws.title = "Batch Details"
_style_header(ws, [("A", "Date"), ("B", "Batch #"), ("C", "Count"), ("D", "Start Time"), ("E", "End Time"), ("F", "Duration (min)")])
for r_idx, row in enumerate(rows, 2):
ws.cell(row=r_idx, column=1, value=row["counting_date"])
ws.cell(row=r_idx, column=2, value=row["batch_number"])
ws.cell(row=r_idx, column=3, value=row["count"])
ws.cell(row=r_idx, column=4, value=row["start_time"])
ws.cell(row=r_idx, column=5, value=row["end_time"])
ws.cell(row=r_idx, column=6, value=row["duration_minutes"] or 0)
_auto_width(ws)
filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return _excel_response(wb, filename)
@app.route("/api/export-day-csv/<date>")
def export_day_xlsx(date):
try:
conn = get_db()
cur = conn.cursor()
cur.execute(
"""
SELECT batch_number, count, start_time, end_time,
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
FROM batches
WHERE counting_date = ?
ORDER BY batch_number ASC
""",
(date,),
)
rows = cur.fetchall()
conn.close()
except sqlite3.OperationalError as e:
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
wb = Workbook()
ws = wb.active
ws.title = f"Day {date}"
_style_header(ws, [("A", "Batch Number"), ("B", "Count"), ("C", "Start Time"), ("D", "End Time"), ("E", "Duration (min)")])
for r_idx, row in enumerate(rows, 2):
ws.cell(row=r_idx, column=1, value=row["batch_number"])
ws.cell(row=r_idx, column=2, value=row["count"])
ws.cell(row=r_idx, column=3, value=row["start_time"])
ws.cell(row=r_idx, column=4, value=row["end_time"])
ws.cell(row=r_idx, column=5, value=row["duration_minutes"] or 0)
_auto_width(ws)
filename = f"{SITE_NAME}_day_detail_{date}.xlsx"
return _excel_response(wb, filename)
if __name__ == "__main__":
import threading
from werkzeug.serving import make_server
WSGIRequestHandler.protocol_version = "HTTP/1.1"
print("=" * 60)
print(f"[*] JETSON BATCH CONTROL & DASHBOARD SERVER STARTED")
print(f"[*] Port Operator (Panel Tombol): http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
print(f"[*] Port Kantor (Monitoring AI): http://{DASHBOARD_HOST}:{OFFICE_PORT}")
print(f"[*] DB Path: {DB_PATH}")
print(f"[*] State : {CURRENT_BATCH_PATH}")
print("=" * 60)
# Server 1: Operator Port (default 5000)
server_operator = make_server(DASHBOARD_HOST, DASHBOARD_PORT, app, threaded=True)
t_op = threading.Thread(target=server_operator.serve_forever, daemon=True)
t_op.start()
# Server 2: Office Monitoring Port (default 5721)
server_office = make_server(DASHBOARD_HOST, OFFICE_PORT, app, threaded=True)
try:
server_office.serve_forever()
except KeyboardInterrupt:
print("\nStopping servers...")
server_operator.shutdown()
server_office.shutdown()