Files
karung-counting-feedmill-se…/counter_dashboard.py
T
2026-09-28 16:33:49 +07:00

1894 lines
71 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 uuid import uuid4
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from flask import Flask, render_template, jsonify, request, Response, send_file, after_this_request
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
from src.do_batch import (
empty_do_settings,
group_dos_by_plate,
is_valid_batch_mode,
is_valid_ocr_engine,
net_counts,
normalize_batch_mode,
OCR_ENGINES,
retention_cutoff_date,
should_discard_batch,
should_purge_counting_date,
start_gate_errors,
)
# Runtime DO settings (require_plate/do + ocr_engine) — separate from config.yaml seed.
if os.name == "nt":
DO_SETTINGS_PATH = _env_or(f"{_DEFAULT_DIR}/do_settings.json", "DO_SETTINGS_PATH")
DO_PHOTO_ROOT = os.path.join(_DEFAULT_DIR, CFG.do.photo_dir)
else:
DO_SETTINGS_PATH = _env_or(os.path.join(_DEFAULT_DIR, "do_settings.json"), "DO_SETTINGS_PATH")
DO_PHOTO_ROOT = _env_or(os.path.join(CFG.output.dir, CFG.do.photo_dir), "DO_PHOTO_ROOT")
@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'"),
("plate", "TEXT NOT NULL DEFAULT ''"),
("do_numbers", "TEXT NOT NULL DEFAULT '[]'"),
("expected_sack", "INTEGER NOT NULL DEFAULT 0"),
("expected_box", "INTEGER NOT NULL DEFAULT 0"),
("net_sack", "INTEGER NOT NULL DEFAULT 0"),
("net_box", "INTEGER NOT NULL DEFAULT 0"),
):
if _col not in _cols:
cur.execute(f"ALTER TABLE batches ADD COLUMN {_col} {_typ}")
# Delivery orders (DO-gated manual batch).
cur.execute(
"""
CREATE TABLE IF NOT EXISTS delivery_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
counting_date TEXT NOT NULL,
photo_path TEXT NOT NULL,
no_do TEXT NOT NULL DEFAULT '',
plate TEXT NOT NULL DEFAULT '',
expected_sack INTEGER NOT NULL DEFAULT 0,
expected_box INTEGER NOT NULL DEFAULT 0,
ocr_raw TEXT,
status TEXT NOT NULL DEFAULT 'draft',
batch_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_do_date_status "
"ON delivery_orders(counting_date, status)"
)
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/DO 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:
expr = "box_loading, box_unloading, model_mode"
else:
expr = "0 AS box_loading, 0 AS box_unloading, 'A' AS model_mode"
do_cols = {
"plate": "'' AS plate",
"do_numbers": "'[]' AS do_numbers",
"expected_sack": "0 AS expected_sack",
"expected_box": "0 AS expected_box",
"net_sack": "0 AS net_sack",
"net_box": "0 AS net_box",
}
for name, lit in do_cols.items():
if name in cols:
expr += f", {name}"
else:
expr += f", {lit}"
return expr
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
def _batch_active() -> bool:
try:
if not os.path.exists(CURRENT_BATCH_PATH):
return False
with open(CURRENT_BATCH_PATH, "r") as f:
curr = json.load(f)
return bool(curr and curr.get("batch_number"))
except Exception:
return False
def _read_do_settings() -> dict:
"""Effective DO settings: config.yaml seed + do_settings.json overrides."""
data = empty_do_settings({
"require_plate": CFG.do.require_plate,
"require_do": CFG.do.require_do,
"ocr_engine": CFG.do.ocr.engine,
})
data["editable"] = is_office_request()
if os.path.exists(DO_SETTINGS_PATH):
try:
with open(DO_SETTINGS_PATH, "r", encoding="utf-8") as f:
stored = json.load(f)
if "require_plate" in stored:
data["require_plate"] = bool(stored["require_plate"])
if "require_do" in stored:
data["require_do"] = bool(stored["require_do"])
if "ocr_engine" in stored and is_valid_ocr_engine(stored["ocr_engine"]):
data["ocr_engine"] = str(stored["ocr_engine"]).lower()
except Exception:
pass
return data
def _write_do_settings(data: dict) -> None:
os.makedirs(os.path.dirname(DO_SETTINGS_PATH) or ".", exist_ok=True)
payload = {
"require_plate": bool(data.get("require_plate", False)),
"require_do": bool(data.get("require_do", True)),
"ocr_engine": str(data.get("ocr_engine", "rapid")).lower(),
"updated_at": datetime.now().isoformat(),
}
tmp = DO_SETTINGS_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
os.replace(tmp, DO_SETTINGS_PATH)
def _effective_ocr_engine() -> str:
return _read_do_settings().get("ocr_engine", CFG.do.ocr.engine)
@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:
mode = normalize_batch_mode(
_read_batch_mode_file()["mode"], CFG.batch.default_mode)
if mode == "auto":
return jsonify({
"success": False,
"reason": "wrong_mode",
"error": "Mode otomatis — batch dibuka/ditutup oleh AI. "
"Alihkan mode dari monitoring.",
}), 409
if _batch_active():
try:
with open(CURRENT_BATCH_PATH, "r") as f:
curr = json.load(f)
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
settings = _read_do_settings()
plate = ""
do_ids: list = []
do_numbers: list = []
expected_sack = 0
expected_box = 0
if mode == "do_manual":
if not CFG.do.enabled:
return jsonify({
"success": False, "reason": "do_disabled",
"error": "DO flow disabled (do.enabled=false)",
}), 400
req_data = request.get_json(silent=True) or request.form or {}
try:
raw_ids = req_data.get("do_ids", [])
if isinstance(raw_ids, str):
raw_ids = [x.strip() for x in raw_ids.split(",") if x.strip()]
do_ids = [int(x) for x in raw_ids]
except (TypeError, ValueError):
return jsonify({
"success": False, "reason": "bad_do_ids",
"error": "do_ids must be list of integers",
}), 400
if not do_ids:
return jsonify({
"success": False, "reason": "missing_do",
"error": "Pilih minimal satu DO sebelum mulai batch.",
}), 400
conn = get_db()
cur = conn.cursor()
placeholders = ",".join("?" * len(do_ids))
cur.execute(
f"SELECT id, no_do, plate, expected_sack, expected_box, status "
f"FROM delivery_orders WHERE id IN ({placeholders})",
do_ids,
)
rows = cur.fetchall()
conn.close()
found = {r["id"]: r for r in rows}
missing = [i for i in do_ids if i not in found]
if missing:
return jsonify({
"success": False, "reason": "missing_do",
"error": f"DO id tidak ditemukan: {missing}",
}), 400
dos = [dict(found[i]) for i in do_ids]
gate = start_gate_errors(
mode, dos,
do_enabled=CFG.do.enabled,
require_do=settings.get("require_do", CFG.do.require_do),
require_plate=settings.get("require_plate", CFG.do.require_plate),
batch_active=_batch_active(),
)
# batch_active already handled; strip if present from early return
gate = [g for g in gate if g != "batch_active"]
if gate:
msg_map = {
"wrong_mode": "Mode batch bukan do_manual.",
"missing_do": "Butuh minimal satu DO.",
"missing_no_do": "Semua DO wajib punya No. DO.",
"missing_plate": "Semua DO wajib punya plat nomor.",
"mixed_plates": "Plat nomor DO berbeda — satu batch = satu plat.",
"do_disabled": "Alur DO dinonaktifkan.",
}
return jsonify({
"success": False,
"reason": gate[0],
"errors": gate,
"error": msg_map.get(gate[0], "; ".join(gate)),
}), 400
try:
plate, dos = group_dos_by_plate(dos)
except ValueError as ve:
return jsonify({
"success": False, "reason": "mixed_plates", "error": str(ve),
}), 400
do_numbers = [str(d.get("no_do") or "") for d in dos]
expected_sack = sum(int(d.get("expected_sack") or 0) for d in dos)
expected_box = sum(int(d.get("expected_box") or 0) for d in dos)
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,
"unloading": 0,
"model_mode": _read_batch_mode_file()["model_mode"],
"start_time": now_iso,
"last_detection_time": now_iso,
"manual_control": True,
"batch_mode": mode,
"plate": plate,
"do_ids": do_ids,
"do_numbers": do_numbers,
"expected_sack": expected_sack,
"expected_box": expected_box,
}
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)
if do_ids:
try:
conn = get_db()
cur = conn.cursor()
for i in do_ids:
cur.execute(
"UPDATE delivery_orders SET status='attached', "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(i,),
)
conn.commit()
conn.close()
except Exception:
pass
return jsonify({
"success": True,
"message": f"Batch #{batch_num} started",
"batch_number": batch_num,
"counting_date": counting_date,
"start_time": now_iso,
"plate": plate,
"do_numbers": do_numbers,
"expected_sack": expected_sack,
"expected_box": expected_box,
})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/batch/stop-preview")
def api_batch_stop_preview():
"""Zone activity hint for stop soft-warn (do_manual / manual)."""
mode = normalize_batch_mode(_read_batch_mode_file()["mode"], CFG.batch.default_mode)
active = _batch_active()
sacks_in_zone = 0
boxes_in_zone = 0
last_det = None
zone_busy = False
try:
if os.path.exists(CURRENT_BATCH_PATH):
with open(CURRENT_BATCH_PATH, "r") as f:
data = json.load(f)
last_det = data.get("last_detection_time")
sacks_in_zone = int(data.get("count") or 0)
boxes_in_zone = int(data.get("box_count") or 0)
if active and last_det and sacks_in_zone > 0:
try:
last = datetime.fromisoformat(last_det)
age = (datetime.now() - last).total_seconds()
zone_busy = age <= float(CFG.do.zone_warn_seconds)
except Exception:
zone_busy = False
except Exception:
pass
return jsonify({
"success": True,
"mode": mode,
"active": active,
"zone_busy": zone_busy,
"sacks_in_zone": sacks_in_zone,
"boxes_in_zone": boxes_in_zone,
"zone_warn_seconds": CFG.do.zone_warn_seconds,
})
@app.route("/api/batch/stop", methods=["POST"])
def api_batch_stop():
try:
mode = normalize_batch_mode(
_read_batch_mode_file()["mode"], CFG.batch.default_mode)
if mode == "auto":
return jsonify({
"success": False, "reason": "wrong_mode",
"error": "Mode otomatis — stop manual tidak berlaku.",
}), 409
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)
unloading = curr.get("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()
plate = curr.get("plate", "") or ""
do_ids = curr.get("do_ids") or []
do_numbers = curr.get("do_numbers") or []
expected_sack = int(curr.get("expected_sack", 0) or 0)
expected_box = int(curr.get("expected_box", 0) or 0)
net_sack, net_box = net_counts(
final_count, unloading, box_final, box_unloading)
discarded = should_discard_batch(final_count, box_final)
if not discarded:
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 = ?,
plate = ?, do_numbers = ?, expected_sack = ?, expected_box = ?,
net_sack = ?, net_box = ?
WHERE counting_date = ? AND batch_number = ? AND camera_name = ? AND object_label = ?
""",
(box_final, box_unloading, model_mode,
plate, json.dumps(do_numbers, ensure_ascii=False),
expected_sack, expected_box, net_sack, net_box,
counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL),
)
except Exception:
pass
if do_ids:
try:
cur.execute(
"SELECT id FROM batches WHERE counting_date=? AND batch_number=? "
"AND camera_name=? AND object_label=?",
(counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL))
brow = cur.fetchone()
if brow:
for do_id in do_ids:
cur.execute(
"UPDATE delivery_orders SET batch_id=?, status='attached', "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(brow[0], do_id))
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()
else:
try:
conn = get_db()
cur = conn.cursor()
for do_id in do_ids:
cur.execute(
"UPDATE delivery_orders SET status='staged', batch_id=NULL, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(do_id,),
)
conn.commit()
conn.close()
except Exception:
pass
try:
os.remove(CURRENT_BATCH_PATH)
except Exception:
pass
return jsonify({
"success": True,
"message": f"Batch #{batch_num} stopped" + (
" (discard: net 0)" if discarded else ""),
"batch_number": batch_num,
"final_count": final_count,
"box_final_count": box_final,
"net_sack": net_sack,
"net_box": net_box,
"discarded": discarded,
"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():
default_mode = normalize_batch_mode(CFG.batch.default_mode, "auto")
data = {"mode": default_mode, "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"] = normalize_batch_mode(stored.get("mode"), default_mode)
# 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 or {}
# Office-only writes for mode / model_mode (operator port → 403).
has_mode = "mode" in req_data
has_model = "model_mode" in req_data
if (has_mode or has_model) and not is_office_request():
return jsonify({
"success": False,
"error": "Mode/model_mode hanya bisa diubah dari port kantor",
}), 403
stored = _read_batch_mode_file()
if has_mode:
mode = str(req_data.get("mode", "")).lower()
if not is_valid_batch_mode(mode):
return jsonify({
"success": False,
"error": "Invalid mode. Use 'auto', 'do_manual' or 'manual'",
}), 400
if _batch_active():
return jsonify({
"success": False,
"reason": "batch_active",
"error": "Batch sedang berjalan — hentikan dulu sebelum ganti mode.",
}), 409
stored["mode"] = mode
if has_model:
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 batch flow 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)
office = is_office_request()
return jsonify({"success": True, "mode": stored["mode"],
"model_mode": stored["model_mode"],
"mode_editable": office,
"model_mode_editable": office,
"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()
office = is_office_request()
return jsonify({"success": True, "mode": stored["mode"],
"model_mode": stored["model_mode"],
"mode_editable": office,
"model_mode_editable": office})
@app.route("/api/model-modes")
def api_model_modes():
stored = _read_batch_mode_file()
office = is_office_request()
return jsonify({
"success": True,
"active": stored["model_mode"],
"editable": office,
"modes": [{"id": m, "description": d}
for m, d in MODEL_MODE_DESCRIPTIONS.items()],
})
@app.route("/api/do/settings", methods=["GET", "POST"])
def api_do_settings():
if request.method == "POST":
try:
req_data = request.get_json(silent=True) or request.form or {}
current = _read_do_settings()
office = is_office_request()
wrote = False
# require_plate / require_do / ocr_engine: office only
gated_keys = ("require_plate", "require_do", "ocr_engine")
if any(k in req_data for k in gated_keys):
if not office:
return jsonify({
"success": False,
"error": "require_plate/require_do/ocr_engine hanya dari port kantor",
}), 403
if "ocr_engine" in req_data:
eng = str(req_data.get("ocr_engine", "")).lower()
if not is_valid_ocr_engine(eng):
return jsonify({
"success": False,
"error": f"Invalid ocr_engine. Use one of {list(OCR_ENGINES)}",
}), 400
current["ocr_engine"] = eng
wrote = True
if "require_plate" in req_data:
current["require_plate"] = bool(
str(req_data.get("require_plate")).lower() in
("1", "true", "yes", "on"))
if "require_do" in req_data:
current["require_do"] = bool(
str(req_data.get("require_do")).lower() in
("1", "true", "yes", "on"))
if any(k in req_data for k in ("require_plate", "require_do")):
wrote = True
if not wrote:
return jsonify({"success": False, "error": "No known settings fields"}), 400
_write_do_settings(current)
current = _read_do_settings()
current["editable"] = office
return jsonify({"success": True, **current})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
data = _read_do_settings()
return jsonify({"success": True, **data})
def _downscale_image(src_path: str, max_edge: int = 2048) -> str:
"""Downscale large phone photos before store/OCR. Returns path to use."""
try:
from PIL import Image # type: ignore
except ImportError:
try:
import cv2 # type: ignore
except ImportError:
return src_path
img = cv2.imread(src_path)
if img is None:
return src_path
h, w = img.shape[:2]
edge = max(h, w)
if edge <= max_edge:
return src_path
scale = max_edge / float(edge)
img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
cv2.imwrite(src_path, img, [int(cv2.IMWRITE_JPEG_QUALITY), 88])
return src_path
try:
img = Image.open(src_path)
edge = max(img.size)
if edge <= max_edge:
return src_path
scale = max_edge / float(edge)
img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
img.save(src_path, "JPEG", quality=88)
return src_path
except Exception:
return src_path
@app.route("/api/do/upload", methods=["POST"])
def api_do_upload():
"""Smartphone multi-photo upload. Only when mode == do_manual."""
try:
mode = normalize_batch_mode(
_read_batch_mode_file()["mode"], CFG.batch.default_mode)
if mode != "do_manual":
return jsonify({
"success": False, "reason": "wrong_mode",
"error": "Upload DO hanya di mode do_manual.",
}), 409
files = request.files.getlist("photos")
files = [f for f in (files or []) if f and f.filename]
if not files:
return jsonify({"success": False, "error": "No photos uploaded"}), 400
if len(files) > int(CFG.do.max_photos_per_batch):
return jsonify({
"success": False,
"error": f"Maksimal {CFG.do.max_photos_per_batch} foto per batch",
}), 400
allowed = {".jpg", ".jpeg", ".png", ".webp"}
counting_date = get_counting_date()
day_dir = os.path.join(DO_PHOTO_ROOT, counting_date)
os.makedirs(day_dir, exist_ok=True)
engine = _effective_ocr_engine()
try:
from src.do_ocr import extract_do_fields
except Exception:
extract_do_fields = None
items = []
conn = get_db()
cur = conn.cursor()
for f in files:
ext = os.path.splitext(f.filename)[1].lower() or ".jpg"
if ext not in allowed:
continue
import uuid
fname = f"do_{uuid.uuid4().hex}{ext}"
rel = os.path.join(CFG.do.photo_dir, counting_date, fname)
abs_path = os.path.join(day_dir, fname)
f.save(abs_path)
_downscale_image(abs_path, max_edge=2048)
ocr = {
"no_do": "", "plate": "", "expected_sack": 0, "expected_box": 0,
"ocr_text": "", "engine": engine, "ocr_ok": False, "ocr_error": "skipped",
}
if extract_do_fields is not None and engine != "none":
try:
ocr = extract_do_fields(abs_path, engine)
except Exception as e:
ocr["ocr_error"] = str(e)
elif engine == "none":
ocr["ocr_error"] = "disabled"
ocr_raw = json.dumps(ocr, ensure_ascii=False)
cur.execute(
"""
INSERT INTO delivery_orders
(counting_date, photo_path, no_do, plate, expected_sack, expected_box, ocr_raw, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'draft')
""",
(counting_date, rel,
ocr.get("no_do", "") or "",
ocr.get("plate", "") or "",
int(ocr.get("expected_sack") or 0),
int(ocr.get("expected_box") or 0),
ocr_raw),
)
do_id = cur.lastrowid
items.append({
"id": do_id,
"photo_url": f"/api/do/photo/{do_id}",
"no_do": ocr.get("no_do", "") or "",
"plate": ocr.get("plate", "") or "",
"expected_sack": int(ocr.get("expected_sack") or 0),
"expected_box": int(ocr.get("expected_box") or 0),
"ocr_text": ocr.get("ocr_text", "") or "",
"ocr_ok": bool(ocr.get("ocr_ok")),
"ocr_error": ocr.get("ocr_error", "") or "",
"status": "draft",
})
conn.commit()
conn.close()
if not items:
return jsonify({"success": False, "error": "No valid image files"}), 400
return jsonify({"success": True, "items": items, "ocr_engine": engine})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/do/photo/<int:do_id>")
def api_do_photo(do_id):
try:
conn = get_db()
cur = conn.cursor()
cur.execute("SELECT photo_path FROM delivery_orders WHERE id=?", (do_id,))
row = cur.fetchone()
conn.close()
if not row or not row["photo_path"]:
return jsonify({"success": False, "error": "Not found"}), 404
rel = row["photo_path"]
# resolve under output.dir; reject traversal
base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR)
full = os.path.abspath(os.path.join(base, rel))
if not full.startswith(base + os.sep) and full != base:
return jsonify({"success": False, "error": "Bad path"}), 400
if not os.path.isfile(full):
# try photo root relative
full2 = os.path.abspath(os.path.join(DO_PHOTO_ROOT, os.path.basename(os.path.dirname(rel)),
os.path.basename(rel)))
if os.path.isfile(full2) and full2.startswith(os.path.abspath(DO_PHOTO_ROOT)):
full = full2
else:
return jsonify({"success": False, "error": "File missing"}), 404
with open(full, "rb") as fh:
data = fh.read()
return Response(data, mimetype="image/jpeg")
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/do/staged")
def api_do_staged():
try:
date = get_counting_date()
conn = get_db()
cur = conn.cursor()
cur.execute(
"""
SELECT id, counting_date, photo_path, no_do, plate, expected_sack,
expected_box, ocr_raw, status, batch_id
FROM delivery_orders
WHERE counting_date = ? AND status IN ('draft', 'staged')
ORDER BY id ASC
""",
(date,),
)
rows = []
for r in cur.fetchall():
ocr = {}
try:
ocr = json.loads(r["ocr_raw"] or "{}")
except Exception:
pass
rows.append({
"id": r["id"],
"photo_url": f"/api/do/photo/{r['id']}",
"no_do": r["no_do"] or ocr.get("no_do", ""),
"plate": r["plate"] or ocr.get("plate", ""),
"expected_sack": r["expected_sack"],
"expected_box": r["expected_box"],
"status": r["status"],
"ocr_ok": bool(ocr.get("ocr_ok")),
"ocr_error": ocr.get("ocr_error", ""),
"ocr_text": ocr.get("ocr_text", ""),
})
conn.close()
return jsonify({"success": True, "items": rows})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/do/<int:do_id>", methods=["PUT", "DELETE"])
def api_do_item(do_id):
try:
conn = get_db()
cur = conn.cursor()
if request.method == "DELETE":
cur.execute("SELECT photo_path, status FROM delivery_orders WHERE id=?", (do_id,))
row = cur.fetchone()
if not row:
conn.close()
return jsonify({"success": False, "error": "Not found"}), 404
if row["status"] == "attached" and _batch_active():
conn.close()
return jsonify({
"success": False, "error": "DO terpasang pada batch aktif",
}), 409
base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR)
full = os.path.abspath(os.path.join(base, row["photo_path"] or ""))
if full.startswith(base) and os.path.isfile(full):
try:
os.remove(full)
except Exception:
pass
cur.execute("DELETE FROM delivery_orders WHERE id=?", (do_id,))
conn.commit()
conn.close()
return jsonify({"success": True, "id": do_id})
req = request.get_json(silent=True) or {}
no_do = str(req.get("no_do", "")).strip()
plate = str(req.get("plate", "")).strip().upper()
try:
expected_sack = int(req.get("expected_sack", 0) or 0)
expected_box = int(req.get("expected_box", 0) or 0)
except (TypeError, ValueError):
conn.close()
return jsonify({"success": False, "error": "expected_* must be int"}), 400
cur.execute(
"""
UPDATE delivery_orders SET
no_do=?, plate=?, expected_sack=?, expected_box=?,
status='staged', updated_at=CURRENT_TIMESTAMP
WHERE id=?
""",
(no_do, plate, expected_sack, expected_box, do_id),
)
if cur.rowcount == 0:
conn.close()
return jsonify({"success": False, "error": "Not found"}), 404
conn.commit()
conn.close()
return jsonify({
"success": True, "id": do_id, "no_do": no_do, "plate": plate,
"expected_sack": expected_sack, "expected_box": expected_box,
"status": "staged",
})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
def _run_do_retention() -> dict:
"""Purge DO rows + photos older than do.retention_days."""
cutoff = retention_cutoff_date(CFG.do.retention_days)
removed_rows = 0
removed_dirs = 0
try:
conn = get_db()
cur = conn.cursor()
cur.execute(
"SELECT id, photo_path FROM delivery_orders WHERE counting_date < ? "
"AND status != 'attached'",
(cutoff,),
)
for r in cur.fetchall():
base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR)
if r["photo_path"]:
full = os.path.abspath(os.path.join(base, r["photo_path"]))
if full.startswith(base) and os.path.isfile(full):
try:
os.remove(full)
except Exception:
pass
cur.execute("UPDATE delivery_orders SET photo_path='' WHERE id=?", (r["id"],))
removed_rows += 1
# clear photo_path on attached old rows after file delete attempt
cur.execute(
"SELECT id, photo_path FROM delivery_orders WHERE counting_date < ? "
"AND status = 'attached' AND photo_path != ''",
(cutoff,),
)
for r in cur.fetchall():
base = os.path.abspath(CFG.output.dir if os.name != "nt" else _DEFAULT_DIR)
full = os.path.abspath(os.path.join(base, r["photo_path"]))
if full.startswith(base) and os.path.isfile(full):
try:
os.remove(full)
except Exception:
pass
cur.execute("UPDATE delivery_orders SET photo_path='' WHERE id=?", (r["id"],))
# delete fully purged old draft/staged leftovers
cur.execute(
"DELETE FROM delivery_orders WHERE counting_date < ? "
"AND status IN ('draft', 'staged')",
(cutoff,),
)
removed_rows += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
conn.commit()
conn.close()
except Exception as e:
print(f"[DO Retention] row purge error: {e}")
try:
if os.path.isdir(DO_PHOTO_ROOT):
for name in os.listdir(DO_PHOTO_ROOT):
if should_purge_counting_date(name, cutoff):
p = os.path.join(DO_PHOTO_ROOT, name)
if os.path.isdir(p):
import shutil
shutil.rmtree(p, ignore_errors=True)
removed_dirs += 1
except Exception as e:
print(f"[DO Retention] photo dir error: {e}")
return {"cutoff": cutoff, "rows": removed_rows, "dirs": removed_dirs}
@app.route("/api/do/retention", methods=["POST"])
def api_do_retention():
if not is_office_request():
return jsonify({"success": False, "error": "Office only"}), 403
try:
return jsonify({"success": True, **_run_do_retention()})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
def _start_do_retention_timer():
import threading
def _loop():
while True:
try:
res = _run_do_retention()
if res.get("rows") or res.get("dirs"):
print(f"[DO Retention] purged rows={res['rows']} dirs={res['dirs']}")
except Exception as e:
print(f"[DO Retention] {e}")
threading.Event().wait(3600.0)
try:
_run_do_retention()
except Exception:
pass
threading.Thread(target=_loop, daemon=True).start()
@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()
loading = int(data.get("count", 0) or 0)
unloading = int(data.get("unloading", 0) or 0)
box_loading = int(data.get("box_count", 0) or 0)
box_unloading = int(data.get("box_unloading", 0) or 0)
net_sack, net_box = net_counts(loading, unloading, box_loading, box_unloading)
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),
"unloading": data.get("unloading", 0),
"net_sack": net_sack,
"net_box": net_box,
"plate": data.get("plate", "") or "",
"do_numbers": data.get("do_numbers") or [],
"expected_sack": int(data.get("expected_sack", 0) or 0),
"expected_box": int(data.get("expected_box", 0) or 0),
"model_mode": data.get("model_mode", stored["model_mode"]),
"mode": stored["mode"],
"batch_mode": data.get("batch_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,
"net_sack": 0,
"net_box": 0,
"plate": "",
"do_numbers": [],
"expected_sack": 0,
"expected_box": 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:
try:
do_numbers = json.loads(row["do_numbers"] or "[]")
except Exception:
do_numbers = []
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"],
"plate": row["plate"] if "plate" in row.keys() else "",
"do_numbers": do_numbers,
"expected_sack": row["expected_sack"] if "expected_sack" in row.keys() else 0,
"expected_box": row["expected_box"] if "expected_box" in row.keys() else 0,
"net_sack": row["net_sack"] if "net_sack" in row.keys() else row["count"],
"net_box": row["net_box"] if "net_box" in row.keys() else row["box_loading"],
"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
try:
do_numbers = json.loads(row["do_numbers"] or "[]")
except Exception:
do_numbers = []
batches.append(
{
"batch_number": row["batch_number"],
"count": row["count"],
"box_loading": row["box_loading"],
"model_mode": row["model_mode"],
"plate": row["plate"] if "plate" in row.keys() else "",
"do_numbers": do_numbers,
"expected_sack": row["expected_sack"] if "expected_sack" in row.keys() else 0,
"expected_box": row["expected_box"] if "expected_box" in row.keys() else 0,
"net_sack": row["net_sack"] if "net_sack" in row.keys() else row["count"],
"net_box": row["net_box"] if "net_box" in row.keys() else row["box_loading"],
"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
_CLIP_TMP_MAX_AGE = 3600 # seconds; stale clip_*.mp4 in $OUTPUT_DIR/tmp
def _unlink_quiet(path):
try:
os.unlink(path)
except OSError:
pass
def _clip_tmp_dir():
tmp_dir = os.path.join(os.path.dirname(DB_PATH), "tmp")
try:
os.makedirs(tmp_dir, exist_ok=True)
except OSError:
pass
try:
cutoff = time.time() - _CLIP_TMP_MAX_AGE
for name in os.listdir(tmp_dir):
if name.startswith("clip_") and name.endswith(".mp4"):
p = os.path.join(tmp_dir, name)
if os.path.getmtime(p) < cutoff:
_unlink_quiet(p)
except Exception:
pass
return tmp_dir
@app.route("/api/batch-clip/<date>/<batch_number>")
def api_batch_clip(date, batch_number):
"""Office-only: clip motionEye recording to the batch window, download as mp4."""
if not is_office_request():
return jsonify({"success": False, "error": "Hanya bisa diakses dari port kantor"}), 403
motion_url = (os.getenv("MOTIONEYE_URL") or "").strip()
if not motion_url:
return jsonify({"success": False, "error": "motionEye belum dikonfigurasi (MOTIONEYE_URL)"}), 404
try:
camera_id = int(os.getenv("MOTIONEYE_CAMERA_ID") or "2")
except ValueError:
camera_id = 2
try:
pad_seconds = float((os.getenv("MOTIONEYE_CLIP_PAD") or "").strip())
except (TypeError, ValueError):
pad_seconds = 3.0
_align_raw = os.getenv("MOTIONEYE_OSD_ALIGN")
align_osd = (
True if _align_raw is None
else _align_raw.strip().lower() not in ("0", "false", "no", "")
)
if not str(batch_number).isdigit():
return jsonify({"success": False, "error": f"batch_number tidak valid: {batch_number}"}), 400
batch_number = int(batch_number)
print(
f"[CLIP] batch={date}/{batch_number} pad={pad_seconds} align={align_osd}",
flush=True,
)
try:
conn = get_db()
cur = conn.cursor()
cur.execute(
"SELECT start_time, end_time FROM batches WHERE counting_date = ? AND batch_number = ?",
(date, batch_number),
)
row = cur.fetchone()
conn.close()
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
if row is None:
return jsonify({"success": False, "error": "Batch tidak ditemukan"}), 404
start_iso, end_iso = row["start_time"], row["end_time"]
if not start_iso or not end_iso:
return jsonify({"success": False, "error": "Batch belum selesai (start/end time kosong)"}), 400
try:
start_dt = datetime.fromisoformat(str(start_iso))
except ValueError:
return jsonify({"success": False, "error": f"Waktu mulai batch tidak valid: {start_iso}"}), 500
# b21-2026-09-26-14-25-09.mp4
filename = f"b{batch_number}-{start_dt.strftime('%Y-%m-%d-%H-%M-%S')}.mp4"
tmp_dir = _clip_tmp_dir()
tmp_path = os.path.join(tmp_dir, f"clip_{uuid4().hex}.mp4")
# Lazy: src.clip (ffmpeg wrapper) not needed to import the dashboard, and
# keeps `import counter_dashboard` working when the module is absent.
try:
from src.clip import ClipError, clip_batch
except Exception as e:
return jsonify({"success": False, "error": f"Modul klip belum tersedia: {e}"}), 503
try:
clip_batch(
motion_url, camera_id, str(start_iso), str(end_iso), tmp_path,
pad_seconds=pad_seconds, align_osd=align_osd,
)
except ClipError as ce:
_unlink_quiet(tmp_path)
msg = str(ce)
low = msg.lower()
no_recording = ("tidak ada rekaman" in low) or ("no recording" in low) or ("not found" in low)
# 404 = no recording in window, 502 = other motionEye/ffmpeg failure.
return jsonify({"success": False, "error": msg}), (404 if no_recording else 502)
except Exception as e:
_unlink_quiet(tmp_path)
return jsonify({"success": False, "error": str(e)}), 500
if not os.path.isfile(tmp_path):
return jsonify({"success": False, "error": "Klip gagal dibuat"}), 502
resp = send_file(tmp_path, as_attachment=True, download_name=filename, mimetype="video/mp4")
# send_file already holds the file open → delete once headers are committed
# (survives a test client that never closes the response) and again when
# the response closes (server side). _unlink_quiet is idempotent.
if not app.config.get("USE_X_SENDFILE"):
@after_this_request
def _cleanup_clip(response):
_unlink_quiet(tmp_path)
return response
resp.call_on_close(lambda: _unlink_quiet(tmp_path))
return resp
@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(
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,),
)
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", "Sacks"), ("C", "Boxes"),
("D", "Plate"), ("E", "DO"), ("F", "DO Sacks"), ("G", "DO Boxes"),
("H", "Start Time"), ("I", "End Time"), ("J", "Duration (min)"),
])
for r_idx, row in enumerate(rows, 2):
try:
do_numbers = json.loads(row["do_numbers"] or "[]")
except Exception:
do_numbers = []
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["box_loading"])
ws.cell(row=r_idx, column=4, value=row["plate"] if "plate" in row.keys() else "")
ws.cell(row=r_idx, column=5, value=", ".join(do_numbers))
ws.cell(row=r_idx, column=6, value=row["expected_sack"] if "expected_sack" in row.keys() else 0)
ws.cell(row=r_idx, column=7, value=row["expected_box"] if "expected_box" in row.keys() else 0)
ws.cell(row=r_idx, column=8, value=row["start_time"])
ws.cell(row=r_idx, column=9, value=row["end_time"])
ws.cell(row=r_idx, column=10, 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"
try:
_start_do_retention_timer()
except Exception as e:
print(f"[DO Retention] start failed: {e}")
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(f"[*] DO photo: {DO_PHOTO_ROOT} (retention {CFG.do.retention_days}d)")
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()