Merge pull request #5 from jasonMPM/v6

Add files via upload
This commit was merged in pull request #5.
This commit is contained in:
jasonMPM
2026-03-04 21:44:23 -06:00
committed by GitHub
2 changed files with 140 additions and 209 deletions

338
app.py
View File

@@ -1,254 +1,180 @@
from flask import Flask, request, jsonify, send_from_directory
from dotenv import load_dotenv
import os, hmac, hashlib, json, logging
from flask import Flask, request, jsonify
from datetime import datetime
import pytz, sqlite3
from apscheduler.schedulers.background import BackgroundScheduler
import sqlite3
import datetime as dt
import requests
import urllib3
import os
import requests, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
load_dotenv()
urllib3.disable_warnings()
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)
DB_PATH = os.environ.get("DB_PATH", "/data/dashboard.db")
UNIFI_HOST = os.environ.get("UNIFI_HOST", "")
UNIFI_API_TOKEN = os.environ.get("UNIFI_API_TOKEN", "")
app = Flask(__name__, static_folder="static", static_url_path="")
UNIFI_HOST = os.environ.get("UNIFI_HOST", "10.0.0.1")
UNIFI_PORT = int(os.environ.get("UNIFI_PORT", "12445"))
UNIFI_TOKEN = os.environ.get("UNIFI_API_TOKEN", "")
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
DB_PATH = os.environ.get("DB_PATH", "/data/dashboard.db")
TZ = os.environ.get("TZ", "America/Chicago")
# ── Database ──────────────────────────────────────────────────────────────────
UNIFI_BASE = f"https://{UNIFI_HOST}:{UNIFI_PORT}/api/v1/developer"
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS unlocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
event TEXT NOT NULL DEFAULT 'access.door.unlock',
door_name TEXT,
device_name TEXT,
actor_id TEXT,
actor_name TEXT,
resolved_name TEXT,
auth_type TEXT,
result TEXT
);
CREATE TABLE IF NOT EXISTS access_users (
id TEXT PRIMARY KEY,
name TEXT,
email TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
""")
for col in ("resolved_name", "device_name"):
try:
conn.execute(f"ALTER TABLE unlocks ADD COLUMN {col} TEXT")
except Exception:
pass
conn.commit()
conn.close()
# ── UniFi user cache ──────────────────────────────────────────────────────────
with get_db() as db:
db.execute("""
CREATE TABLE IF NOT EXISTS badge_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id TEXT NOT NULL,
ts TEXT NOT NULL,
date TEXT NOT NULL
)
""")
db.execute("""
CREATE TABLE IF NOT EXISTS user_cache (
actor_id TEXT PRIMARY KEY,
full_name TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
db.commit()
def sync_unifi_users():
if not UNIFI_HOST or not UNIFI_API_TOKEN:
print("[UniFi Sync] UNIFI_HOST or UNIFI_API_TOKEN not set — skipping.")
return
url = f"https://{UNIFI_HOST}:45/api1/users"
headers = {"Authorization": f"Bearer {UNIFI_API_TOKEN}"}
try:
resp = requests.get(url, headers=headers, verify=False, timeout=10)
resp.raise_for_status()
users = resp.json().get("data", [])
conn = get_db()
for user in users:
conn.execute(
"""INSERT INTO access_users (id, name, email, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name,
email=excluded.email,
updated_at=excluded.updated_at""",
(user.get("id"), user.get("name"), user.get("email", "")),
)
conn.commit()
conn.close()
print(f"[UniFi Sync] Cached {len(users)} users.")
r = requests.get(
f"{UNIFI_BASE}/users",
headers={"Authorization": f"Bearer {UNIFI_TOKEN}"},
verify=False, timeout=10
)
if r.status_code != 200:
log.warning("User sync failed: %s %s", r.status_code, r.text[:200])
return
users = r.json().get("data", [])
with get_db() as db:
for u in users:
db.execute("""
INSERT INTO user_cache (actor_id, full_name, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(actor_id) DO UPDATE SET
full_name = excluded.full_name,
updated_at = excluded.updated_at
""", (u["id"], u.get("full_name", "").strip() or
f"{u.get('first_name','')} {u.get('last_name','')}".strip(),
datetime.utcnow().isoformat()))
db.commit()
log.info("Synced %d users from UniFi Access", len(users))
except Exception as e:
print(f"[UniFi Sync] Failed: {e}")
log.error("sync_unifi_users error: %s", e)
def verify_signature(payload_bytes, sig_header):
"""Return True if HMAC-SHA256 signature matches, or if no secret configured."""
if not WEBHOOK_SECRET:
return True
expected = hmac.new(WEBHOOK_SECRET.encode(), payload_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig_header or "")
def resolve_user_name(user_id: str) -> str:
if not user_id:
return "Unknown"
conn = get_db()
row = conn.execute(
"SELECT name FROM access_users WHERE id = ?", (user_id,)
).fetchone()
conn.close()
return row["name"] if row else f"Unknown ({user_id[:8]}…)"
@app.route("/")
def index():
return app.send_static_file("index.html")
@app.route("/api/unifi-access", methods=["POST"])
def receive_webhook():
raw = request.get_data()
# ── Webhook receiver ──────────────────────────────────────────────────────────
# Optional signature verification
sig = request.headers.get("X-Signature-SHA256", "")
if not verify_signature(raw, sig):
log.warning("Webhook signature mismatch")
return jsonify({"error": "invalid signature"}), 401
@app.post("/api/unifi-access")
def unifi_access_webhook():
payload = request.get_json(force=True, silent=True) or {}
ts = dt.datetime.utcnow().isoformat(timespec="seconds") + "Z"
try:
payload = json.loads(raw)
except Exception:
return jsonify({"error": "bad json"}), 400
if payload.get("event") == "access.door.unlock":
data = payload.get("data", {})
actor = data.get("actor", {})
location = data.get("location", {})
device = data.get("device", {})
obj = data.get("object", {})
actor_id = actor.get("id", "")
actor_name = actor.get("name") or resolve_user_name(actor_id)
door_name = location.get("name")
device_name = device.get("name")
auth_type = obj.get("authentication_type")
result = obj.get("result", "Access Granted")
log.info("Webhook received: %s", json.dumps(payload)[:300])
elif "events" in payload:
event = payload["events"][0]
actor_id = event.get("user", "")
actor_name = event.get("user_name") or resolve_user_name(actor_id)
door_name = event.get("location_name") or event.get("location", "Unknown Door")
device_name = None
auth_type = event.get("unlock_method_text", "Unknown")
result = "Access Granted"
# Support both UniFi Access developer API and legacy Alarm Manager formats
event = payload.get("event") or payload.get("event_object_id", "")
actor = (payload.get("actor") or {}).get("id") or payload.get("actor_id", "")
ts_raw = payload.get("timestamp") or payload.get("created_at") or datetime.utcnow().isoformat()
else:
return "", 204
if "door.unlock" not in str(event) and "access.door.unlock" not in str(event):
return jsonify({"status": "ignored"}), 200
conn = get_db()
conn.execute(
"""INSERT INTO unlocks
(ts, event, door_name, device_name, actor_id,
actor_name, resolved_name, auth_type, result)
VALUES (?, 'access.door.unlock', ?, ?, ?, ?, ?, ?, ?)""",
(ts, door_name, device_name, actor_id, actor_name,
actor_name, auth_type, result),
)
conn.commit()
conn.close()
return "", 204
if not actor:
return jsonify({"error": "no actor"}), 400
tz = pytz.timezone(TZ)
ts = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
ts_l = ts.astimezone(tz)
date = ts_l.strftime("%Y-%m-%d")
ts_s = ts_l.strftime("%H:%M:%S")
# ── Dashboard data API ────────────────────────────────────────────────────────
with get_db() as db:
db.execute(
"INSERT INTO badge_events (actor_id, ts, date) VALUES (?, ?, ?)",
(actor, ts_s, date)
)
db.commit()
@app.get("/api/first-badge-status")
log.info("Badge-in recorded: actor=%s date=%s ts=%s", actor, date, ts_s)
return jsonify({"status": "ok"}), 200
@app.route("/api/first-badge-status")
def first_badge_status():
date = request.args.get("date") or dt.date.today().isoformat()
date = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
cutoff = request.args.get("cutoff", "09:00")
start = f"{date}T00:00:00Z"
end = f"{date}T23:59:59Z"
conn = get_db()
# First badge per person
first_rows = conn.execute(
"""SELECT COALESCE(resolved_name, actor_name, actor_id) AS display_name,
actor_id,
MIN(ts) AS first_ts
FROM unlocks
WHERE event = 'access.door.unlock'
AND result = 'Access Granted'
AND ts BETWEEN ? AND ?
AND (actor_id IS NOT NULL OR actor_name IS NOT NULL)
GROUP BY actor_id
ORDER BY first_ts""",
(start, end),
).fetchall()
# Latest badge per person (may equal first if they only badged once)
latest_rows = conn.execute(
"""SELECT actor_id, MAX(ts) AS latest_ts
FROM unlocks
WHERE event = 'access.door.unlock'
AND result = 'Access Granted'
AND ts BETWEEN ? AND ?
AND actor_id IS NOT NULL
GROUP BY actor_id""",
(start, end),
).fetchall()
conn.close()
latest_map = {r["actor_id"]: r["latest_ts"] for r in latest_rows}
with get_db() as db:
rows = db.execute("""
SELECT
b.actor_id,
MIN(b.ts) AS first_ts,
MAX(b.ts) AS latest_ts,
COALESCE(u.full_name, 'Unknown (' || SUBSTR(b.actor_id,1,8) || '...)') AS name
FROM badge_events b
LEFT JOIN user_cache u ON u.actor_id = b.actor_id
WHERE b.date = ?
GROUP BY b.actor_id
ORDER BY first_ts ASC
""", (date,)).fetchall()
result = []
for r in first_rows:
t_first = dt.datetime.fromisoformat(r["first_ts"].replace("Z", "+00:00"))
first_time = t_first.strftime("%H:%M")
latest_ts = latest_map.get(r["actor_id"], r["first_ts"])
t_latest = dt.datetime.fromisoformat(latest_ts.replace("Z", "+00:00"))
latest_time = t_latest.strftime("%H:%M")
for r in rows:
first = r["first_ts"]
latest = r["latest_ts"]
status = "ON TIME" if first <= cutoff + ":59" else "LATE"
result.append({
"actor_name": r["display_name"],
"actor_id": r["actor_id"],
"first_badge": r["first_ts"],
"badge_time": first_time,
"latest_badge": latest_ts,
"latest_time": latest_time,
"on_time": first_time <= cutoff,
"actor_id": r["actor_id"],
"name": r["name"],
"first_ts": first,
"latest_ts": latest if latest != first else None,
"status": status
})
return jsonify(result)
# ── Sync users ────────────────────────────────────────────────────────────────
@app.get("/api/sync-users")
@app.route("/api/sync-users")
def manual_sync():
sync_unifi_users()
return jsonify({"status": "ok"})
return jsonify({"status": "synced"})
# ── Reset day (testing only) ──────────────────────────────────────────────────
@app.delete("/api/reset-day")
@app.route("/api/reset-day", methods=["DELETE"])
def reset_day():
"""Delete all unlock records for a given date (defaults to today).
Intended for development/testing only.
"""
date = request.args.get("date") or dt.date.today().isoformat()
start = f"{date}T00:00:00Z"
end = f"{date}T23:59:59Z"
conn = get_db()
res = conn.execute(
"DELETE FROM unlocks WHERE ts BETWEEN ? AND ?", (start, end)
)
conn.commit()
deleted = res.rowcount
conn.close()
return jsonify({"status": "ok", "deleted": deleted, "date": date})
# ── Static files ──────────────────────────────────────────────────────────────
@app.get("/")
def index():
return send_from_directory("static", "index.html")
@app.get("/static/<path:path>")
def send_static(path):
return send_from_directory("static", path)
# ── Startup ───────────────────────────────────────────────────────────────────
date = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
with get_db() as db:
cur = db.execute("DELETE FROM badge_events WHERE date = ?", (date,))
db.commit()
return jsonify({"status": "ok", "deleted": cur.rowcount, "date": date})
# Initialise DB and kick off background scheduler at import time
# (works whether started via `flask run` or `python app.py`)
with app.app_context():
init_db()
sync_unifi_users()

View File

@@ -1,6 +1,6 @@
version: "3.9"
version: "3.8"
services:
unifi-access-dashboard:
dashboard:
build: .
container_name: unifi-access-dashboard
restart: unless-stopped
@@ -11,4 +11,9 @@ services:
env_file:
- .env
environment:
- TZ=America/Chicago
- UNIFI_HOST=${UNIFI_HOST}
- UNIFI_PORT=${UNIFI_PORT:-12445}
- UNIFI_API_TOKEN=${UNIFI_API_TOKEN}
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
- TZ=${TZ:-America/Chicago}
- DB_PATH=${DB_PATH:-/data/dashboard.db}