forked from ariska/karung-web-admin
update untuk manajemen 1 kandang
This commit is contained in:
1 parent
e97225808a
commit
8bbe5950ea
12 files changed
+549
-323
No files matched your search
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,2 +1,74 @@
|
||||
# karung-web-admin
|
||||
|
||||
Admin dashboard that aggregates **KTC** (tuang) and **KPC** (masuk) counter data and exposes a single combined API.
|
||||
|
||||
## Sources
|
||||
|
||||
| Role | Project | Default URL | Cameras |
|
||||
|------|---------|-------------|---------|
|
||||
| Tuang | `zenai-ktc-python` | `http://192.168.192.15:9000` | `K1-L1-left`, `K1-L1-right` |
|
||||
| Masuk | `zenai-kpc-python` | `http://192.168.192.15:7000` | `K1-L1-in`, `K1-L1-out` |
|
||||
|
||||
## Install path
|
||||
|
||||
Deploy code to `/opt/karung-web-admin` (matches the systemd units below).
|
||||
|
||||
## Puller (systemd service)
|
||||
|
||||
```bash
|
||||
sudo cp karung-web-admin-pull-sources.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now karung-web-admin-pull-sources.service
|
||||
```
|
||||
|
||||
It runs `pull_sources.py --loop` and refreshes every `SOURCE_PULL_INTERVAL_SECONDS` (default 30).
|
||||
|
||||
Writes:
|
||||
|
||||
- Live JSON: `karung_tuang.json`, `karung_masuk.json`
|
||||
- History SQLite: `karung_tuang.db`, `karung_masuk.db`
|
||||
- Status: `.pull_status.json`
|
||||
|
||||
One-shot (optional / debugging):
|
||||
|
||||
```bash
|
||||
python pull_sources.py
|
||||
```
|
||||
|
||||
## Web app
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
Or enable the dashboard service:
|
||||
|
||||
```bash
|
||||
sudo cp karung-web-admin.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now karung-web-admin.service
|
||||
```
|
||||
|
||||
If you previously used `frigate-counter_karung-web-admin.service`, disable/remove it:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now frigate-counter_karung-web-admin.service
|
||||
sudo rm /etc/systemd/system/frigate-counter_karung-web-admin.service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
Dashboard: `http://0.0.0.0:8090/`
|
||||
Combined API: `GET /api/k1/combined?history_days=30`
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Default |
|
||||
|----------|---------|
|
||||
| `KTC_BASE_URL` | `http://192.168.192.15:9000` |
|
||||
| `KPC_BASE_URL` | `http://192.168.192.15:7000` |
|
||||
| `SOURCE_HISTORY_DAYS` | `30` |
|
||||
| `SOURCE_PULL_INTERVAL_SECONDS` | `30` |
|
||||
| `KARUNG_TUANG_JSON` | `karung_tuang.json` |
|
||||
| `KARUNG_MASUK_JSON` | `karung_masuk.json` |
|
||||
| `KARUNG_TUANG_DB` | `karung_tuang.db` |
|
||||
| `KARUNG_MASUK_DB` | `karung_masuk.db` |
|
||||
@@ -50,14 +50,10 @@ load_env_file(BASE_DIR / '.env')
|
||||
|
||||
KARUNG_TUANG_DB = get_configured_db_path('KARUNG_TUANG_DB', 'karung_tuang.db')
|
||||
KARUNG_MASUK_DB = get_configured_db_path('KARUNG_MASUK_DB', 'karung_masuk.db')
|
||||
KARUNG_TUANG_JSON = Path(os.environ.get(
|
||||
'KARUNG_TUANG_JSON',
|
||||
'/etc/frigate-counter/karung-tuang-2/karung_tuang.json'
|
||||
))
|
||||
KARUNG_MASUK_JSON = Path(os.environ.get(
|
||||
'KARUNG_MASUK_JSON',
|
||||
'/etc/frigate-counter/karung-masuk/karung_masuk.json'
|
||||
))
|
||||
KARUNG_TUANG_JSON = get_configured_db_path('KARUNG_TUANG_JSON', 'karung_tuang.json')
|
||||
KARUNG_MASUK_JSON = get_configured_db_path('KARUNG_MASUK_JSON', 'karung_masuk.json')
|
||||
PULL_STATUS_FILE = BASE_DIR / '.pull_status.json'
|
||||
DEFAULT_COMBINED_HISTORY_DAYS = int(os.environ.get('SOURCE_HISTORY_DAYS', '30'))
|
||||
|
||||
if KARUNG_TUANG_DB.resolve() == KARUNG_MASUK_DB.resolve():
|
||||
raise RuntimeError('KARUNG_TUANG_DB and KARUNG_MASUK_DB must point to different database files.')
|
||||
@@ -91,11 +87,18 @@ DEFAULT_CUTOFF_TIME_OPTIONS = [
|
||||
|
||||
def get_db_data(db_path, table_name):
|
||||
"""Fetch all data from database"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT id, camera_name, date, counter_value FROM {table_name} ORDER BY date DESC")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT id, camera_name, date, counter_value FROM {table_name} ORDER BY date DESC")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
except sqlite3.OperationalError:
|
||||
return []
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
@@ -454,7 +457,8 @@ def filter_data_by_cycle(data, cycle_start, cycle_end):
|
||||
return filtered_data
|
||||
|
||||
def is_masuk_out_item(item):
|
||||
return str(item['camera_name']).lower().endswith('_out')
|
||||
camera_name = str(item['camera_name']).lower()
|
||||
return camera_name.endswith('_out') or camera_name.endswith('-out')
|
||||
|
||||
def get_settings_from_form():
|
||||
cycle_name = request.form.get('cycle_name', '').strip()
|
||||
@@ -722,6 +726,65 @@ def format_history_items(data):
|
||||
for item in data
|
||||
]
|
||||
|
||||
def load_pull_status():
|
||||
if not PULL_STATUS_FILE.exists():
|
||||
return {
|
||||
'ktc': {'status': 'unknown'},
|
||||
'kpc': {'status': 'unknown'},
|
||||
}
|
||||
|
||||
try:
|
||||
with PULL_STATUS_FILE.open(encoding='utf-8') as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {
|
||||
'ktc': {'status': 'unknown'},
|
||||
'kpc': {'status': 'unknown'},
|
||||
}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
'ktc': {'status': 'unknown'},
|
||||
'kpc': {'status': 'unknown'},
|
||||
}
|
||||
|
||||
return {
|
||||
'ktc': data.get('ktc') or {'status': 'unknown'},
|
||||
'kpc': data.get('kpc') or {'status': 'unknown'},
|
||||
}
|
||||
|
||||
def filter_history_by_days(data, days):
|
||||
if days is None or days < 1:
|
||||
return data
|
||||
|
||||
cutoff_date = datetime.now().date() - timedelta(days=days - 1)
|
||||
filtered = []
|
||||
for item in data:
|
||||
item_date = parse_data_date(item['date'])
|
||||
if item_date and item_date >= cutoff_date:
|
||||
filtered.append(item)
|
||||
return filtered
|
||||
|
||||
def build_combined_site_payload(source_name, today_items, history_items, is_masuk=False):
|
||||
today_total = sum(item['karung'] for item in today_items)
|
||||
if is_masuk:
|
||||
history_total = sum(
|
||||
item['counter_value'] if not is_masuk_out_item(item) else -item['counter_value']
|
||||
for item in history_items
|
||||
)
|
||||
else:
|
||||
history_total = sum(item['counter_value'] for item in history_items)
|
||||
|
||||
return {
|
||||
'source': source_name,
|
||||
'today': today_items,
|
||||
'history': format_history_items(history_items),
|
||||
'totals': {
|
||||
'today': today_total,
|
||||
'history': history_total,
|
||||
},
|
||||
}
|
||||
|
||||
def get_active_cycle_dates(dashboard_settings):
|
||||
cycle_start = parse_cycle_date(dashboard_settings['cycle_start'], 'Cycle start')
|
||||
cycle_end = parse_cycle_date(dashboard_settings['cycle_end'], 'Cycle end')
|
||||
@@ -863,6 +926,55 @@ def get_active_cycle_sections():
|
||||
},
|
||||
})
|
||||
|
||||
@app.route('/api/k1/combined', methods=['GET'])
|
||||
def get_k1_combined():
|
||||
history_days_raw = request.args.get('history_days', DEFAULT_COMBINED_HISTORY_DAYS)
|
||||
try:
|
||||
history_days = int(history_days_raw)
|
||||
except (TypeError, ValueError):
|
||||
abort(400, description='history_days must be a whole number.')
|
||||
|
||||
if history_days < 1:
|
||||
abort(400, description='history_days must be at least 1.')
|
||||
|
||||
tuang_today = get_json_counter_items(get_json_data(KARUNG_TUANG_JSON))
|
||||
masuk_today = get_json_counter_items(get_json_data(KARUNG_MASUK_JSON))
|
||||
|
||||
tuang_history = filter_history_by_days(
|
||||
get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table']),
|
||||
history_days,
|
||||
)
|
||||
masuk_history = filter_history_by_days(
|
||||
get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']),
|
||||
history_days,
|
||||
)
|
||||
|
||||
tuang_payload = build_combined_site_payload('ktc', tuang_today, tuang_history, is_masuk=False)
|
||||
masuk_payload = build_combined_site_payload('kpc', masuk_today, masuk_history, is_masuk=True)
|
||||
pull_status = load_pull_status()
|
||||
|
||||
response = api_success_response({
|
||||
'tuang': tuang_payload,
|
||||
'masuk': masuk_payload,
|
||||
'totals': {
|
||||
'tuang': tuang_payload['totals']['today'],
|
||||
'masuk': sum(
|
||||
item['karung'] if not is_masuk_out_item({'camera_name': item['camera_name']}) else -item['karung']
|
||||
for item in masuk_today
|
||||
),
|
||||
},
|
||||
})
|
||||
payload = response.get_json()
|
||||
payload['meta']['sources'] = {
|
||||
'ktc': pull_status.get('ktc') or {'status': 'unknown'},
|
||||
'kpc': pull_status.get('kpc') or {'status': 'unknown'},
|
||||
}
|
||||
payload['meta']['history_days'] = history_days
|
||||
response = jsonify(payload)
|
||||
response.status_code = 200
|
||||
response.headers['X-Request-ID'] = payload['meta']['requestId']
|
||||
return response
|
||||
|
||||
@app.route('/database/<db_name>/<int:row_id>/edit', methods=['POST'])
|
||||
def edit_db_row(db_name, row_id):
|
||||
db_config = DB_CONFIGS.get(db_name)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
COUNTER_UUID=9f556d2a-af5c-41b0-bcd0-6cea0dfa0fed
|
||||
+2
-2
@@ -38,8 +38,8 @@ def get_settings_file_candidates(base_dir=None):
|
||||
current_dir.parent / COUNTER_SETTINGS_FILE_NAME,
|
||||
current_dir.parent / "karung-web-admin" / COUNTER_SETTINGS_FILE_NAME,
|
||||
current_dir.parent / "karung-web" / COUNTER_SETTINGS_FILE_NAME,
|
||||
Path("/etc/frigate-counter/karung-web-admin") / COUNTER_SETTINGS_FILE_NAME,
|
||||
Path("/etc/frigate-counter/karung-web") / COUNTER_SETTINGS_FILE_NAME,
|
||||
Path("/opt/karung-web-admin") / COUNTER_SETTINGS_FILE_NAME,
|
||||
Path("/opt/karung-web") / COUNTER_SETTINGS_FILE_NAME,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=Admin Karung Counter Web
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/etc/frigate-counter/karung-web-admin
|
||||
ExecStart=/usr/bin/python3 -u /etc/frigate-counter/karung-web-admin/app.py
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Pull KTC/KPC counters into karung-web-admin
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/karung-web-admin
|
||||
Environment=KTC_BASE_URL=http://192.168.192.15:9000
|
||||
Environment=KPC_BASE_URL=http://192.168.192.15:7000
|
||||
Environment=SOURCE_HISTORY_DAYS=30
|
||||
Environment=SOURCE_PULL_INTERVAL_SECONDS=30
|
||||
ExecStart=/usr/bin/python3 -u /opt/karung-web-admin/pull_sources.py --loop
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Karung Web Admin Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/karung-web-admin
|
||||
ExecStart=/usr/bin/python3 -u /opt/karung-web-admin/app.py
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"kandang_1_karung_masuk": {
|
||||
"K1-L1-in": {
|
||||
"karung": 0
|
||||
},
|
||||
"kandang_1_karung_masuk_out": {
|
||||
"K1-L1-out": {
|
||||
"karung": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"karung_tuang_feeder_kiri_atas": {
|
||||
"K1-L1-left": {
|
||||
"karung": 0
|
||||
},
|
||||
"karung_tuang_feeder_kanan_bawah": {
|
||||
"K1-L1-right": {
|
||||
"karung": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pull counter data from KTC (tuang) and KPC (masuk) dashboards into local JSON + SQLite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
DEFAULT_KTC_BASE_URL = "http://192.168.192.15:9000"
|
||||
DEFAULT_KPC_BASE_URL = "http://192.168.192.15:7000"
|
||||
DEFAULT_HISTORY_DAYS = 30
|
||||
DEFAULT_PULL_INTERVAL_SECONDS = 30
|
||||
PULL_STATUS_FILE = BASE_DIR / ".pull_status.json"
|
||||
|
||||
KTC_LIVE_FIELDS = (
|
||||
("count_left", "K1-L1-left"),
|
||||
("count_right", "K1-L1-right"),
|
||||
)
|
||||
KPC_LIVE_FIELDS = (
|
||||
("count_in", "K1-L1-in"),
|
||||
("count_out", "K1-L1-out"),
|
||||
)
|
||||
KTC_HISTORY_FIELDS = (
|
||||
("total_left", "K1-L1-left"),
|
||||
("total_right", "K1-L1-right"),
|
||||
)
|
||||
KPC_HISTORY_FIELDS = (
|
||||
("total_in", "K1-L1-in"),
|
||||
("total_out", "K1-L1-out"),
|
||||
)
|
||||
|
||||
|
||||
def get_utc_timestamp():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def get_configured_path(env_name, default_file_name):
|
||||
path = Path(os.environ.get(env_name, default_file_name)).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = BASE_DIR / path
|
||||
return path
|
||||
|
||||
|
||||
def get_int_env(env_name, default_value):
|
||||
raw = os.environ.get(env_name)
|
||||
if raw is None or raw.strip() == "":
|
||||
return default_value
|
||||
return int(raw)
|
||||
|
||||
|
||||
def map_live_payload(payload, field_map):
|
||||
"""Map /api/current-counter fields to live JSON camera entries."""
|
||||
mapped = {}
|
||||
for field_name, camera_name in field_map:
|
||||
value = payload.get(field_name, 0)
|
||||
try:
|
||||
karung = int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
karung = 0
|
||||
mapped[camera_name] = {"karung": karung}
|
||||
return mapped
|
||||
|
||||
|
||||
def map_history_rows(rows, field_map):
|
||||
"""Map /api/history data rows to (camera_name, date, counter_value) tuples."""
|
||||
mapped_rows = []
|
||||
for row in rows or []:
|
||||
counting_date = row.get("date") or row.get("counting_date")
|
||||
if not counting_date:
|
||||
continue
|
||||
for field_name, camera_name in field_map:
|
||||
try:
|
||||
value = int(row.get(field_name, 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
value = 0
|
||||
mapped_rows.append((camera_name, str(counting_date), value))
|
||||
return mapped_rows
|
||||
|
||||
|
||||
def write_live_json(json_path, data):
|
||||
json_path = Path(json_path)
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with json_path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def ensure_tuang_table(conn):
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS counter_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
camera_name TEXT NOT NULL,
|
||||
date TIMESTAMP NOT NULL,
|
||||
counter_value INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def ensure_masuk_table(conn):
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS karung_counts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
camera_name TEXT NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
counter_value INTEGER NOT NULL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upsert_counter_rows(db_path, table_name, rows):
|
||||
"""Upsert counter rows by (camera_name, date)."""
|
||||
db_path = Path(db_path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
if table_name == "counter_data":
|
||||
ensure_tuang_table(conn)
|
||||
date_expr = "date(date)"
|
||||
else:
|
||||
ensure_masuk_table(conn)
|
||||
date_expr = "date"
|
||||
|
||||
for camera_name, counting_date, counter_value in rows:
|
||||
existing = conn.execute(
|
||||
f"""
|
||||
SELECT id FROM {table_name}
|
||||
WHERE camera_name = ? AND {date_expr} = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(camera_name, counting_date),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
conn.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET counter_value = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(counter_value, existing[0]),
|
||||
)
|
||||
else:
|
||||
if table_name == "karung_counts":
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO karung_counts (camera_name, date, counter_value)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(camera_name, counting_date, counter_value),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO counter_data (camera_name, date, counter_value)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(camera_name, counting_date, counter_value),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_json(url, timeout=30):
|
||||
request = urllib.request.Request(url, method="GET")
|
||||
request.add_header("Accept", "application/json")
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def load_pull_status():
|
||||
if not PULL_STATUS_FILE.exists():
|
||||
return {"ktc": {"status": "unknown"}, "kpc": {"status": "unknown"}}
|
||||
try:
|
||||
with PULL_STATUS_FILE.open(encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return {"ktc": {"status": "unknown"}, "kpc": {"status": "unknown"}}
|
||||
|
||||
|
||||
def save_pull_status(status):
|
||||
with PULL_STATUS_FILE.open("w", encoding="utf-8") as handle:
|
||||
json.dump(status, handle, indent=2)
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def pull_source(
|
||||
*,
|
||||
source_key,
|
||||
base_url,
|
||||
live_fields,
|
||||
history_fields,
|
||||
json_path,
|
||||
db_path,
|
||||
table_name,
|
||||
history_days,
|
||||
):
|
||||
result = {
|
||||
"status": "ok",
|
||||
"pulled_at": get_utc_timestamp(),
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
current = fetch_json(f"{base_url.rstrip('/')}/api/current-counter")
|
||||
live_data = map_live_payload(current, live_fields)
|
||||
write_live_json(json_path, live_data)
|
||||
|
||||
history_payload = fetch_json(
|
||||
f"{base_url.rstrip('/')}/api/history?days={history_days}"
|
||||
)
|
||||
history_rows = history_payload.get("data", []) if isinstance(history_payload, dict) else []
|
||||
mapped_history = map_history_rows(history_rows, history_fields)
|
||||
if mapped_history:
|
||||
upsert_counter_rows(db_path, table_name, mapped_history)
|
||||
|
||||
result["live_cameras"] = list(live_data.keys())
|
||||
result["history_rows"] = len(mapped_history)
|
||||
return result
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError, OSError, ValueError) as error:
|
||||
result["status"] = "error"
|
||||
result["error"] = str(error)
|
||||
return result
|
||||
|
||||
|
||||
def run_pull():
|
||||
ktc_base = os.environ.get("KTC_BASE_URL", DEFAULT_KTC_BASE_URL).strip() or DEFAULT_KTC_BASE_URL
|
||||
kpc_base = os.environ.get("KPC_BASE_URL", DEFAULT_KPC_BASE_URL).strip() or DEFAULT_KPC_BASE_URL
|
||||
history_days = get_int_env("SOURCE_HISTORY_DAYS", DEFAULT_HISTORY_DAYS)
|
||||
|
||||
tuang_json = get_configured_path("KARUNG_TUANG_JSON", "karung_tuang.json")
|
||||
masuk_json = get_configured_path("KARUNG_MASUK_JSON", "karung_masuk.json")
|
||||
tuang_db = get_configured_path("KARUNG_TUANG_DB", "karung_tuang.db")
|
||||
masuk_db = get_configured_path("KARUNG_MASUK_DB", "karung_masuk.db")
|
||||
|
||||
status = load_pull_status()
|
||||
status["ktc"] = pull_source(
|
||||
source_key="ktc",
|
||||
base_url=ktc_base,
|
||||
live_fields=KTC_LIVE_FIELDS,
|
||||
history_fields=KTC_HISTORY_FIELDS,
|
||||
json_path=tuang_json,
|
||||
db_path=tuang_db,
|
||||
table_name="counter_data",
|
||||
history_days=history_days,
|
||||
)
|
||||
status["kpc"] = pull_source(
|
||||
source_key="kpc",
|
||||
base_url=kpc_base,
|
||||
live_fields=KPC_LIVE_FIELDS,
|
||||
history_fields=KPC_HISTORY_FIELDS,
|
||||
json_path=masuk_json,
|
||||
db_path=masuk_db,
|
||||
table_name="karung_counts",
|
||||
history_days=history_days,
|
||||
)
|
||||
status["updated_at"] = get_utc_timestamp()
|
||||
save_pull_status(status)
|
||||
|
||||
print(f"KTC: {status['ktc']['status']}" + (f" ({status['ktc']['error']})" if status["ktc"].get("error") else ""))
|
||||
print(f"KPC: {status['kpc']['status']}" + (f" ({status['kpc']['error']})" if status["kpc"].get("error") else ""))
|
||||
|
||||
if status["ktc"]["status"] != "ok" or status["kpc"]["status"] != "ok":
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def run_pull_loop(interval_seconds=None):
|
||||
if interval_seconds is None:
|
||||
interval_seconds = get_int_env("SOURCE_PULL_INTERVAL_SECONDS", DEFAULT_PULL_INTERVAL_SECONDS)
|
||||
if interval_seconds < 1:
|
||||
interval_seconds = DEFAULT_PULL_INTERVAL_SECONDS
|
||||
|
||||
print(f"Starting source pull loop every {interval_seconds}s")
|
||||
while True:
|
||||
run_pull()
|
||||
time.sleep(interval_seconds)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = list(argv if argv is not None else sys.argv[1:])
|
||||
if "--loop" in args:
|
||||
run_pull_loop()
|
||||
return 0
|
||||
return run_pull()
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
-284
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sync counter data from SQLite databases to API.
|
||||
|
||||
karung_tuang:
|
||||
- 17:00-23:59: Syncs CURRENT date
|
||||
- 00:00-16:59: Syncs PREVIOUS date (only if not already synced)
|
||||
|
||||
karung_masuk: Syncs PREVIOUS date from 00:00 onwards, only if data differs
|
||||
(skips if already successfully synced for that date).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from counter_time import get_business_date, load_counter_settings
|
||||
|
||||
|
||||
def get_db_path(db_name):
|
||||
"""Get database path in same directory as script."""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(script_dir, db_name)
|
||||
|
||||
|
||||
def get_previous_business_date(counter_type, settings):
|
||||
"""Get the previously completed business date in YYYY-MM-DD format."""
|
||||
return (get_business_date(counter_type, settings) - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def get_sync_state_file():
|
||||
"""Get path to sync state file."""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(script_dir, ".sync_state.json")
|
||||
|
||||
|
||||
def load_sync_state():
|
||||
"""Load sync state from file."""
|
||||
state_file = get_sync_state_file()
|
||||
if os.path.exists(state_file):
|
||||
try:
|
||||
with open(state_file, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_sync_state(state):
|
||||
"""Save sync state to file."""
|
||||
state_file = get_sync_state_file()
|
||||
try:
|
||||
with open(state_file, "w") as f:
|
||||
json.dump(state, f)
|
||||
except IOError as e:
|
||||
print(f"Warning: Could not save sync state: {e}")
|
||||
|
||||
|
||||
def is_already_synced(state, db_name, date):
|
||||
"""Check if database was already synced for given date."""
|
||||
return state.get(db_name, {}).get("last_synced_date") == date
|
||||
|
||||
|
||||
def mark_synced(state, db_name, date):
|
||||
"""Mark database as synced for given date."""
|
||||
if db_name not in state:
|
||||
state[db_name] = {}
|
||||
state[db_name]["last_synced_date"] = date
|
||||
|
||||
|
||||
def get_current_business_date(counter_type, settings):
|
||||
"""Get the active business date in YYYY-MM-DD format."""
|
||||
return get_business_date(counter_type, settings).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def get_local_data(db_path, table_name, date):
|
||||
"""Get counter data from SQLite database for a specific date."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
if table_name == "karung_counts":
|
||||
cursor.execute(
|
||||
"SELECT camera_name, date, counter_value FROM karung_counts WHERE date = ?",
|
||||
(date,),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"SELECT camera_name, date(date) as d, counter_value FROM counter_data WHERE date(date) = ?",
|
||||
(date,),
|
||||
)
|
||||
|
||||
results = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
data = {}
|
||||
for camera_name, date_val, counter_value in results:
|
||||
key = f"{camera_name}_{date_val}"
|
||||
data[key] = {
|
||||
"camera_name": camera_name,
|
||||
"date": date_val,
|
||||
"value": counter_value,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
def fetch_api_data(uuid):
|
||||
"""Fetch counter data from API."""
|
||||
url = f"https://dashboard.cpsp.id/api/cpsp/counter/{uuid}/"
|
||||
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
req.add_header("Accept", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
if result.get("success") and "data" in result:
|
||||
return result["data"].get("camera_counter", [])
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"HTTP Error {e.code}: {e.reason}")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"URL Error: {e.reason}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"JSON Decode Error: {e}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def update_api(uuid, camera_name, date, value):
|
||||
"""Update counter data via API (dummy endpoint)."""
|
||||
# Dummy endpoint - replace with actual endpoint when available
|
||||
url = f"https://dashboard.cpsp.id/api/cpsp/counter/{uuid}/update/"
|
||||
|
||||
payload = {"camera_name": camera_name, "date": date, "value": value}
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Accept", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
print(f"Updated {camera_name} for {date}: {value}")
|
||||
return result
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"HTTP Error {e.code} updating {camera_name}: {e.reason}")
|
||||
# For dummy endpoint, just log the intended update
|
||||
print(f"[DUMMY] Would update {camera_name} for {date} with value {value}")
|
||||
return None
|
||||
except urllib.error.URLError as e:
|
||||
print(f"URL Error: {e.reason}")
|
||||
print(f"[DUMMY] Would update {camera_name} for {date} with value {value}")
|
||||
return None
|
||||
|
||||
|
||||
def sync_database(db_config, date, uuid, state=None, skip_if_synced=False):
|
||||
"""Sync a single database with API.
|
||||
|
||||
Args:
|
||||
db_config: Database configuration dict
|
||||
date: Date string to sync
|
||||
uuid: API UUID
|
||||
state: Optional sync state dict
|
||||
skip_if_synced: If True, skip sync if already marked as synced for this date
|
||||
"""
|
||||
db_name = db_config.get("name", "unknown")
|
||||
|
||||
# Check if already synced (for karung_masuk to avoid redundant syncs)
|
||||
if skip_if_synced and state and is_already_synced(state, db_name, date):
|
||||
print(f"Skipping {db_name} - already synced for {date}")
|
||||
return True
|
||||
|
||||
db_path = get_db_path(db_config["db_file"])
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database not found: {db_path}")
|
||||
return False
|
||||
|
||||
# Get local data
|
||||
local_data = get_local_data(db_path, db_config["table"], date)
|
||||
|
||||
if not local_data:
|
||||
print(f"No local data found for {date} in {db_config['db_file']}")
|
||||
return False
|
||||
|
||||
# Get API data
|
||||
api_data = fetch_api_data(uuid)
|
||||
|
||||
# Build API data map
|
||||
api_map = {}
|
||||
for item in api_data:
|
||||
key = f"{item['camera_name']}_{item['date']}"
|
||||
api_map[key] = item["value"]
|
||||
|
||||
has_mismatch = False
|
||||
|
||||
# Compare and sync
|
||||
for key, local_record in local_data.items():
|
||||
camera_name = local_record["camera_name"]
|
||||
local_value = local_record["value"]
|
||||
|
||||
if key not in api_map:
|
||||
print(f"New record: {camera_name} for {date} = {local_value}")
|
||||
update_api(uuid, camera_name, date, local_value)
|
||||
has_mismatch = True
|
||||
elif api_map[key] != local_value:
|
||||
print(
|
||||
f"Mismatch for {camera_name} on {date}: API={api_map[key]}, Local={local_value}"
|
||||
)
|
||||
update_api(uuid, camera_name, date, local_value)
|
||||
has_mismatch = True
|
||||
else:
|
||||
print(f"Synced: {camera_name} for {date} = {local_value}")
|
||||
|
||||
# Mark as synced if no mismatches found (all data matches)
|
||||
if not has_mismatch and state:
|
||||
mark_synced(state, db_name, date)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main sync function.
|
||||
|
||||
karung_tuang:
|
||||
- 17:00-23:59: Sync CURRENT date
|
||||
- 00:00-16:59: Sync PREVIOUS date (only if not already synced)
|
||||
|
||||
karung_masuk: Sync PREVIOUS date from 00:00 onwards, only if data differs.
|
||||
"""
|
||||
uuid = os.environ.get("COUNTER_UUID")
|
||||
if not uuid:
|
||||
print("Error: COUNTER_UUID environment variable not set")
|
||||
return
|
||||
|
||||
# Load sync state to track which dates have been synced
|
||||
sync_state = load_sync_state()
|
||||
counter_settings = load_counter_settings()
|
||||
|
||||
# Database configurations
|
||||
db_configs = {
|
||||
"karung_tuang": {
|
||||
"name": "karung_tuang",
|
||||
"counter_type": "tuang",
|
||||
"db_file": "karung_tuang.db",
|
||||
"table": "counter_data",
|
||||
"skip_if_synced": False,
|
||||
},
|
||||
"karung_masuk": {
|
||||
"name": "karung_masuk",
|
||||
"counter_type": "masuk",
|
||||
"db_file": "karung_masuk.db",
|
||||
"table": "karung_counts",
|
||||
"skip_if_synced": True, # Skip if already synced for this date
|
||||
},
|
||||
}
|
||||
|
||||
print(f"Timezone: {counter_settings['local_timezone']}")
|
||||
|
||||
# Sync the business date that has most recently closed for each counter.
|
||||
for name, config in db_configs.items():
|
||||
sync_date = get_previous_business_date(config["counter_type"], counter_settings)
|
||||
active_date = get_current_business_date(config["counter_type"], counter_settings)
|
||||
|
||||
print(f"\nSyncing {name} for completed business date {sync_date} (active date {active_date})...")
|
||||
sync_database(
|
||||
config,
|
||||
sync_date,
|
||||
uuid,
|
||||
state=sync_state,
|
||||
skip_if_synced=config.get("skip_if_synced", False),
|
||||
)
|
||||
|
||||
# Save sync state
|
||||
save_sync_state(sync_state)
|
||||
|
||||
print("\nSync complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user