From 9c5206ae59aada0edc09b6c1bf45d5a43558d661 Mon Sep 17 00:00:00 2001 From: jasonMPM Date: Wed, 4 Mar 2026 19:42:38 -0600 Subject: [PATCH] Add files via upload --- Dockerfile | 5 +- README.md | 225 ++++++++++++++++++++----------- app.py | 232 ++++++++++++++++++++++---------- docker-compose.yml | 2 + requirements.txt | 4 + static/index.html | 320 ++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 641 insertions(+), 147 deletions(-) diff --git a/Dockerfile b/Dockerfile index eb9a54c..78552ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,10 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -ENV FLASK_APP=app.py FLASK_RUN_HOST=0.0.0.0 FLASK_RUN_PORT=8000 DB_PATH=/data/events.db +ENV FLASK_APP=app.py \ + FLASK_RUN_HOST=0.0.0.0 \ + FLASK_RUN_PORT=8000 \ + DB_PATH=/data/dashboard.db EXPOSE 8000 diff --git a/README.md b/README.md index 7b70c10..9712c90 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,186 @@ -# UniFi Access Badge-In Dashboard +# UniFi Access Badge-In Dashboard (V2) -A small Flask + SQLite web app that receives UniFi Access `access.door.unlock` webhooks and shows a dark, gold-accented dashboard of daily first badge-in times. +A Dockerised Flask + SQLite web app that receives UniFi Access `access.door.unlock` webhooks, +resolves UUID actor IDs to real user names via the UniFi Access API, +and displays a modern dark dashboard with on-time / late status per person. + +--- ## Features -- Receives UniFi Access webhooks for `access.door.unlock` events and stores them in SQLite. -- Modern dark UI with black background, gold accents, and on-time (green) vs late (red) status. -- Date picker and configurable "badged in by" cutoff time. -- Dockerised for easy deployment on Unraid. +- Receives `access.door.unlock` webhooks from either the **UniFi Access developer API** or the legacy **Alarm Manager** format. +- Resolves blank `user_name` UUIDs to real names by querying the UniFi Access REST API on a schedule (every 6 hours) and caching results in SQLite. +- Manual **Sync Users** button in the UI triggers an immediate cache refresh. +- Dark theme with gold accents, green ON TIME / red LATE status chips. +- Date picker + configurable "badged in by" cutoff time. +- Fully Dockerised — single container, persisted SQLite volume. -## Repository layout +--- -- `app.py` – Flask application and API endpoints. -- `requirements.txt` – Python dependencies. -- `Dockerfile` – Container image definition. -- `docker-compose.yml` – Example compose file (works on Unraid). -- `static/index.html` – Single‑page dashboard UI. +## Project layout -## UniFi Access configuration - -1. Ensure you have UniFi Access running (UA Ultra / UA Hub Door Mini / G3 Intercom etc.). -2. In the UniFi Access web UI, open the API / developer section and create a **Webhook**:[web:24][web:25] - - Method: `POST`. - - URL: `http://:8000/unifi-access-webhook` (or behind HTTPS via reverse proxy). - - Events: at least `access.door.unlock`. -3. Save and trigger a test door unlock. You should see webhook hits in the container logs and rows in `events.db`. - -## Building and running on Unraid - -### 1. Create a public GitHub repository - -1. On your workstation, create a new folder and put all files from this project in it. -2. Initialize a Git repo, commit, and push to GitHub (public or private with a token): - -```bash -git init -git add . -git commit -m "Initial UniFi Access dashboard" -git branch -M main -git remote add origin https://github.com//.git -git push -u origin main +``` +. +├── app.py # Flask application +├── requirements.txt +├── Dockerfile +├── docker-compose.yml +├── .env.example # Copy to .env and fill in your values +├── .gitignore +└── static/ + └── index.html # Dashboard UI ``` -### 2. Add a new Docker template on Unraid +--- -You can either use the **Docker** tab (Add Container) or deploy via the Unraid terminal. +## Step 1 — Generate a UniFi Access API token -#### Option A – Using Unraid GUI +1. Open your **UniFi OS** web interface. +2. Navigate to **UniFi Access → Settings → Integrations** (or the **Developer API** section). +3. Create a new **API Key** (Bearer Token). Copy it — you will need it below. -1. Go to **Docker → Add Container**. -2. Set **Name** to `unifi-access-dashboard`. -3. For **Repository**, point to your GitHub repo using the GitHub URL with the `Dockerfile` as build context if you build externally, or build the image locally first (Option B). Unraid’s GUI typically expects an image name on Docker Hub; easiest approach is: - - Build and push your image from a machine with Docker: +> Use a dedicated read-only admin account to generate the token if possible. + +--- + +## Step 2 — Create your .env file + +Copy `.env.example` to `.env` and fill in your values: ```bash -docker build -t /unifi-access-dashboard:latest . -docker push /unifi-access-dashboard:latest +cp .env.example .env ``` - - Then in Unraid, set **Repository** to `/unifi-access-dashboard:latest`. -4. Add a **Port mapping**: host `8000` → container `8000`. -5. Add a **Path mapping** for persistent DB: - - Host path: `/mnt/user/appdata/unifi-access-dashboard/` - - Container path: `/data` -6. Add environment variable `TZ` to match your timezone (e.g., `America/Chicago`). -7. Apply to start the container. +```dotenv +UNIFI_HOST=192.168.1.1 # IP of your UniFi OS controller +UNIFI_API_TOKEN=YOUR_TOKEN_HERE +TZ=America/Chicago +DB_PATH=/data/dashboard.db +``` -#### Option B – Using `docker-compose` on Unraid +> **Never commit `.env` to git.** It is listed in `.gitignore`. -If you prefer to build directly on the Unraid box and pull source from GitHub: +--- -1. SSH into Unraid. -2. Clone your GitHub repo: +## Step 3 — Register the webhook with UniFi Access + +Run this once from any machine on the same LAN as your controller (replace placeholders): + +```bash +curl -k -X POST "https://192.168.1.1:45/api1/webhooks/endpoints" \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Dashboard Unlock Events", + "endpoint": "http://YOUR_UNRAID_IP:8000/api/unifi-access", + "events": ["access.door.unlock"], + "headers": { "X-Source": "unifi-access" } + }' +``` + +Verify it was registered: + +```bash +curl -k -X GET "https://192.168.1.1:45/api1/webhooks/endpoints" \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + +You should see your webhook listed with a unique `id`. + +> **Note:** The UniFi Access API runs on **port 45** over HTTPS with a self-signed cert. +> Always pass `-k` to curl, or `verify=False` in Python requests. + +--- + +## Step 4 — Deploy on Unraid + +### Clone from GitHub and build locally + +SSH into your Unraid server: ```bash cd /mnt/user/appdata git clone https://github.com//.git unifi-access-dashboard cd unifi-access-dashboard -``` - -3. (Optional) Adjust `docker-compose.yml` ports or paths. -4. Build and start: - -```bash +cp .env.example .env +nano .env # fill in UNIFI_HOST and UNIFI_API_TOKEN docker compose up -d --build ``` -5. The app will listen on port `8000` by default. +The app is now running on **port 8000**. -### 3. Verify the app +### Unraid GUI (Docker tab — Add Container) -1. In a browser, open `http://:8000/`. -2. You should see the dark dashboard with date and cutoff selectors. -3. After some badge-in activity, click **Refresh** and verify that users show as **ON TIME** (green) or **LATE** (red) depending on the cutoff. +If you prefer using the GUI after pushing an image to Docker Hub: -## Environment and volumes +```bash +# On your workstation: +docker build -t /unifi-access-dashboard:latest . +docker push /unifi-access-dashboard:latest +``` -- `DB_PATH` (optional) – path to the SQLite file inside the container (defaults to `/data/events.db` via Dockerfile). -- Mount `/data` to persistent storage on Unraid so badge history survives container restarts. +Then in Unraid → Docker → Add Container: -## Time zones and "on time" logic +| Field | Value | +|---|---| +| Name | unifi-access-dashboard | +| Repository | `/unifi-access-dashboard:latest` | +| Port | Host `8000` → Container `8000` | +| Path | Host `/mnt/user/appdata/unifi-access-dashboard/data` → Container `/data` | +| Env: UNIFI_HOST | `192.168.1.x` | +| Env: UNIFI_API_TOKEN | `your-token` | +| Env: TZ | `America/Chicago` | -- Webhook timestamps are stored in UTC with a `Z` suffix. -- The "badged in by" cutoff is interpreted in 24‑hour `HH:MM` format and compared against the stored time string for that day. -- If you need strict local‑time handling, you can extend `app.py` to convert UTC to your timezone before comparison. +--- +## Step 5 — Open the dashboard + +Browse to: + +``` +http://:8000/ +``` + +- Choose a **date** and set the **"Badged in by"** cutoff time (e.g. `09:00`). +- Click **Refresh** to load the day's first badge-in per person. +- Green chip = on time. Red chip = late. +- Click **Sync Users** to immediately pull the latest user list from your UniFi Access controller. + +--- + +## Keeping the user cache fresh + +The app automatically re-syncs users from UniFi Access every **6 hours** in the background. +If you add or rename a badge holder, click **Sync Users** in the dashboard or restart the container. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Names show as `Unknown (UUID…)` | Users not yet cached | Click Sync Users or wait for the 6-hour job | +| Webhook not arriving | Firewall / Docker network | Ensure port 8000 is reachable from the UniFi controller | +| `curl` returns SSL error | Self-signed cert | Add `-k` to bypass; already handled in Python | +| 404 on `/api1/users` | Firmware difference | Try `/api/v1/users`; check your Access app version | +| Duplicate events | Both Alarm Manager and API webhook active | Remove one, or they will both store rows (harmless but duplicates) | +| Container exits | `.env` missing | Ensure `.env` exists and `docker compose up` picks it up | + +--- + +## Updating from GitHub + +```bash +cd /mnt/user/appdata/unifi-access-dashboard +git pull +docker compose up -d --build +``` + +--- + +## Security notes + +- The `.env` file is excluded from git via `.gitignore`. +- The UniFi API token is never exposed to the frontend. +- Mount `/data` to persistent storage so badge history survives container restarts. +- For external access, place a reverse proxy (Nginx/Traefik) with HTTPS in front. diff --git a/app.py b/app.py index cc80566..44fb3fd 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,23 @@ from flask import Flask, request, jsonify, send_from_directory +from dotenv import load_dotenv +from apscheduler.schedulers.background import BackgroundScheduler import sqlite3 import datetime as dt +import requests +import urllib3 import os -app = Flask(__name__) -DB_PATH = os.environ.get("DB_PATH", "events.db") +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +load_dotenv() +app = Flask(__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", "") + + +# ── Database ────────────────────────────────────────────────────────────────── def get_db(): conn = sqlite3.connect(DB_PATH) @@ -16,116 +28,192 @@ def get_db(): @app.before_first_request def init_db(): conn = get_db() - conn.execute( - """ - CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL, - event TEXT NOT NULL, - door_name TEXT, - device_name TEXT, - actor_id TEXT, - actor_name TEXT, - auth_type TEXT, - result TEXT - ) - """ - ) + 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 + ); + """) + # migration: add resolved_name if upgrading from V1 + try: + conn.execute("ALTER TABLE unlocks ADD COLUMN resolved_name TEXT") + except Exception: + pass conn.commit() conn.close() -@app.post("/unifi-access-webhook") +# ── UniFi user cache ────────────────────────────────────────────────────────── + +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.") + except Exception as e: + print(f"[UniFi Sync] Failed: {e}") + + +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]}…)" + + +# ── Webhook receiver ────────────────────────────────────────────────────────── + +@app.post("/api/unifi-access") def unifi_access_webhook(): payload = request.get_json(force=True, silent=True) or {} - event = payload.get("event") - data = payload.get("data", {}) - - if event != "access.door.unlock": - return "", 204 - - actor = data.get("actor", {}) - location = data.get("location", {}) - device = data.get("device", {}) - obj = data.get("object", {}) ts = dt.datetime.utcnow().isoformat(timespec="seconds") + "Z" + # ── New developer-API webhook format ────────────────────────────────────── + 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") + + # ── Legacy Alarm Manager format ─────────────────────────────────────────── + 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" + + else: + return "", 204 # unrecognised — silently ignore + conn = get_db() conn.execute( - """ - INSERT INTO events (ts, event, door_name, device_name, - actor_id, actor_name, auth_type, result) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - ts, - event, - location.get("name"), - device.get("name"), - actor.get("id"), - actor.get("name"), - obj.get("authentication_type"), - obj.get("result"), - ), + """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 +# ── Dashboard data API ──────────────────────────────────────────────────────── + @app.get("/api/first-badge-status") def first_badge_status(): - date = request.args.get("date") or dt.date.today().isoformat() - cutoff = request.args.get("cutoff", "09:00") # HH:MM - start = f"{date}T00:00:00Z" - end = f"{date}T23:59:59Z" + date = request.args.get("date") or dt.date.today().isoformat() + cutoff = request.args.get("cutoff", "09:00") + start = f"{date}T00:00:00Z" + end = f"{date}T23:59:59Z" conn = get_db() rows = conn.execute( - """ - SELECT actor_name, actor_id, MIN(ts) AS first_ts - FROM events - WHERE event = 'access.door.unlock' - AND result = 'Access Granted' - AND ts BETWEEN ? AND ? - AND actor_id IS NOT NULL - GROUP BY actor_id, actor_name - ORDER BY first_ts - """, + """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() conn.close() result = [] for r in rows: - first_ts = r["first_ts"] - t = dt.datetime.fromisoformat(first_ts.replace("Z", "+00:00")) - badge_time_str = t.strftime("%H:%M") - on_time = badge_time_str <= cutoff - result.append( - { - "actor_name": r["actor_name"], - "actor_id": r["actor_id"], - "first_badge": first_ts, - "badge_time": badge_time_str, - "on_time": on_time, - } - ) + t = dt.datetime.fromisoformat(r["first_ts"].replace("Z", "+00:00")) + badge_time = t.strftime("%H:%M") + result.append({ + "actor_name": r["display_name"], + "actor_id": r["actor_id"], + "first_badge": r["first_ts"], + "badge_time": badge_time, + "on_time": badge_time <= cutoff, + }) return jsonify(result) +@app.get("/api/sync-users") +def manual_sync(): + sync_unifi_users() + return jsonify({"status": "ok"}) + + +# ── Static files ────────────────────────────────────────────────────────────── + @app.get("/") def index(): return send_from_directory("static", "index.html") - @app.get("/static/") def send_static(path): return send_from_directory("static", path) +# ── Startup ─────────────────────────────────────────────────────────────────── + +scheduler = BackgroundScheduler() +scheduler.add_job(sync_unifi_users, "interval", hours=6) +scheduler.start() + if __name__ == "__main__": + with app.app_context(): + init_db() + sync_unifi_users() app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8000))) diff --git a/docker-compose.yml b/docker-compose.yml index f46c4a6..1e0a0b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,5 +8,7 @@ services: - "8000:8000" volumes: - ./data:/data + env_file: + - .env environment: - TZ=America/Chicago diff --git a/requirements.txt b/requirements.txt index 2e7aeea..55936ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,5 @@ flask==3.0.2 +python-dotenv==1.0.1 +requests==2.31.0 +apscheduler==3.10.4 +urllib3==2.2.1 diff --git a/static/index.html b/static/index.html index aa65ab1..d6be9d9 100644 --- a/static/index.html +++ b/static/index.html @@ -1 +1,319 @@ -Replace this with full index.html from the assistant response. \ No newline at end of file + + + + + UniFi Access Attendance + + + + +
+
+
+

Building Access

+
Daily badge-in attendance — powered by UniFi Access
+
+
LIVE ATTENDANCE DASHBOARD
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
0 on time
+
0 late
+
0 total
+
+ +
+ + + + + + + + + + + + + +
#NameBadge TimeActor IDStatus
No data yet. Badge into a door and press Refresh.
+
+
+ +
+ + + +