#!/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/") 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/") 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()