diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b569964 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +.env +*.db diff --git a/README.md b/README.md index 1bdd79d..413c7c7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,61 @@ # adacam-api -Clean Python Flask replacement for odc-api (Hivemapper Bee liberation) \ No newline at end of file +Clean Python Flask replacement for Hivemapper's `odc-api` — a 434k-line Node.js monolith with a filed CVE. This service runs on the **Hivemapper Bee (HDC-S)** dashcam as part of the **adacam** liberation stack. + +## What it does + +- Serves API endpoints for the Varroa Android app and adamaps-forwarder +- Reads GPS data from Redis (`GNSSFusion30Hz`) +- Stores landmark detections in SQLite +- Forwards detections to AdaMaps API (with offline queuing) + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/1/landmarks/last/{N}` | Last N detections | +| POST | `/api/1/landmarks` | Ingest new detection | +| GET | `/api/1/gnssConcise/latestValid` | Current GPS fix | +| GET | `/api/1/status` | Device status | +| GET | `/api/1/deviceinfo` | Device identity | +| GET | `/api/1/recording/frames/latest` | Latest frame path | + +**Note:** `/api/1/cmd` is intentionally NOT implemented — that was the CVE. + +## Installation + +```bash +./install.sh +``` + +This will: +1. Copy files to `/opt/adacam/` +2. Install Python dependencies +3. Enable and start the systemd service +4. Generate a device ID on first run + +## Configuration + +Config file: `/data/adacam/config.json` + +```json +{ + "device_id": "auto-generated UUID", + "adamaps_key": "adamaps-ingest-2026", + "adamaps_api": "https://api.adamaps.org", + "ap_interface": "wlp1s0f0", + "tunnel_host": "", + "tunnel_user": "", + "tunnel_port": 2222 +} +``` + +## Requirements + +- Python 3.8+ +- Redis (for GPS/IMU data) +- Flask, redis-py, requests + +## License + +MIT diff --git a/adacam_api/__init__.py b/adacam_api/__init__.py new file mode 100644 index 0000000..9c88510 --- /dev/null +++ b/adacam_api/__init__.py @@ -0,0 +1,2 @@ +"""adacam-api: Clean replacement for odc-api.""" +__version__ = "1.0.0" diff --git a/adacam_api/app.py b/adacam_api/app.py new file mode 100644 index 0000000..c5f5049 --- /dev/null +++ b/adacam_api/app.py @@ -0,0 +1,24 @@ +"""Flask app factory.""" +from flask import Flask +from . import config, db, forwarder +from .routes import landmarks, gnss, status, frames + + +def create_app(): + """Create and configure the Flask app.""" + app = Flask(__name__) + + # Load config and init DB + config.load() + db.init() + + # Register blueprints + app.register_blueprint(landmarks.bp) + app.register_blueprint(gnss.bp) + app.register_blueprint(status.bp) + app.register_blueprint(frames.bp) + + # Start background forwarder thread + forwarder.start_retry_thread() + + return app diff --git a/adacam_api/config.py b/adacam_api/config.py new file mode 100644 index 0000000..b425e69 --- /dev/null +++ b/adacam_api/config.py @@ -0,0 +1,52 @@ +"""Config management for adacam-api.""" +import json +import os +import uuid + +CONFIG_PATH = "/data/adacam/config.json" +FIRMWARE_VERSION = "adacam-1.0.0" + +_defaults = { + "device_id": None, + "adamaps_key": "adamaps-ingest-2026", + "adamaps_api": "https://api.adamaps.org", + "ap_interface": "wlp1s0f0", + "tunnel_host": "", + "tunnel_user": "", + "tunnel_port": 2222, +} + +_config = None + + +def load(): + """Load config from disk, creating defaults if needed.""" + global _config + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + + if os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH) as f: + _config = {**_defaults, **json.load(f)} + else: + _config = _defaults.copy() + + # Generate device_id on first run + if not _config.get("device_id"): + _config["device_id"] = str(uuid.uuid4()) + save() + + return _config + + +def save(): + """Persist config to disk.""" + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, "w") as f: + json.dump(_config, f, indent=2) + + +def get(key, default=None): + """Get a config value.""" + if _config is None: + load() + return _config.get(key, default) diff --git a/adacam_api/db.py b/adacam_api/db.py new file mode 100644 index 0000000..63c1a0c --- /dev/null +++ b/adacam_api/db.py @@ -0,0 +1,88 @@ +"""SQLite database for landmarks and offline queue.""" +import sqlite3 +import threading +import json + +DB_PATH = "/data/adacam/adacam.db" +_local = threading.local() + + +def get_conn(): + """Get thread-local database connection.""" + if not hasattr(_local, "conn"): + _local.conn = sqlite3.connect(DB_PATH, check_same_thread=False) + _local.conn.row_factory = sqlite3.Row + return _local.conn + + +def init(): + """Initialize database schema.""" + conn = get_conn() + conn.executescript(""" + CREATE TABLE IF NOT EXISTS landmarks ( + id INTEGER PRIMARY KEY, + class_label TEXT, + class_label_confidence REAL, + overall_confidence REAL, + lat REAL, lon REAL, alt REAL, + azimuth REAL, + width INTEGER, height INTEGER, + ts INTEGER, + forwarded INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS forward_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + payload TEXT, + created_at INTEGER DEFAULT (strftime('%s','now')) + ); + CREATE INDEX IF NOT EXISTS idx_landmarks_ts ON landmarks(ts DESC); + CREATE INDEX IF NOT EXISTS idx_landmarks_forwarded ON landmarks(forwarded); + """) + conn.commit() + + +def insert_landmark(data): + """Insert a landmark detection.""" + conn = get_conn() + conn.execute(""" + INSERT INTO landmarks (id, class_label, class_label_confidence, overall_confidence, + lat, lon, alt, azimuth, width, height, ts) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (data.get("id"), data["class_label"], data.get("class_label_confidence"), + data.get("overall_confidence"), data.get("lat"), data.get("lon"), + data.get("alt"), data.get("azimuth"), data.get("width"), + data.get("height"), data.get("ts"))) + conn.commit() + return conn.execute("SELECT last_insert_rowid()").fetchone()[0] + + +def get_last_landmarks(n): + """Get last N landmarks.""" + conn = get_conn() + rows = conn.execute("SELECT * FROM landmarks ORDER BY ts DESC LIMIT ?", (n,)).fetchall() + return [dict(r) for r in rows] + + +def queue_forward(payload): + """Queue a payload for later forwarding.""" + conn = get_conn() + conn.execute("INSERT INTO forward_queue (payload) VALUES (?)", (json.dumps(payload),)) + conn.commit() + + +def pop_queue(limit=50): + """Pop items from forward queue.""" + conn = get_conn() + rows = conn.execute("SELECT id, payload FROM forward_queue ORDER BY id LIMIT ?", (limit,)).fetchall() + if rows: + ids = [r["id"] for r in rows] + conn.execute(f"DELETE FROM forward_queue WHERE id IN ({','.join('?'*len(ids))})", ids) + conn.commit() + return [(r["id"], json.loads(r["payload"])) for r in rows] + + +def mark_forwarded(landmark_id): + """Mark a landmark as forwarded.""" + conn = get_conn() + conn.execute("UPDATE landmarks SET forwarded = 1 WHERE id = ?", (landmark_id,)) + conn.commit() diff --git a/adacam_api/forwarder.py b/adacam_api/forwarder.py new file mode 100644 index 0000000..1259a37 --- /dev/null +++ b/adacam_api/forwarder.py @@ -0,0 +1,83 @@ +"""AdaMaps forwarding with offline queue.""" +import threading +import time +import requests +from . import config, db + +_thread = None +_running = False + + +def forward_detection(data): + """Forward a detection to AdaMaps, queue if offline.""" + api_url = config.get("adamaps_api", "https://api.adamaps.org") + api_key = config.get("adamaps_key", "") + device_id = config.get("device_id", "unknown") + + payload = { + "device_id": device_id, + "detection": data, + } + + try: + resp = requests.post( + f"{api_url}/api/ingest", + json=payload, + headers={"X-AdaMaps-Key": api_key}, + timeout=10, + ) + if resp.status_code == 200: + return True + except requests.RequestException: + pass + + # Queue for retry + db.queue_forward(payload) + return False + + +def _retry_loop(): + """Background thread to retry queued forwards.""" + global _running + while _running: + try: + items = db.pop_queue(limit=20) + if not items: + time.sleep(30) + continue + + api_url = config.get("adamaps_api", "https://api.adamaps.org") + api_key = config.get("adamaps_key", "") + + for _, payload in items: + try: + resp = requests.post( + f"{api_url}/api/ingest", + json=payload, + headers={"X-AdaMaps-Key": api_key}, + timeout=10, + ) + if resp.status_code != 200: + db.queue_forward(payload) + except requests.RequestException: + db.queue_forward(payload) + time.sleep(5) + + time.sleep(5) + except Exception: + time.sleep(30) + + +def start_retry_thread(): + """Start the background retry thread.""" + global _thread, _running + if _thread is None or not _thread.is_alive(): + _running = True + _thread = threading.Thread(target=_retry_loop, daemon=True) + _thread.start() + + +def stop_retry_thread(): + """Stop the background retry thread.""" + global _running + _running = False diff --git a/adacam_api/redis_client.py b/adacam_api/redis_client.py new file mode 100644 index 0000000..78a2f53 --- /dev/null +++ b/adacam_api/redis_client.py @@ -0,0 +1,40 @@ +"""Redis client for GPS and IMU data.""" +import json +import redis + +_client = None + + +def get_client(): + """Get Redis connection.""" + global _client + if _client is None: + _client = redis.Redis(host="localhost", port=6379, decode_responses=True) + return _client + + +def get_latest_gnss(): + """Get latest GPS fix from GNSSFusion30Hz ZSET.""" + client = get_client() + try: + # Get the most recent entry (highest score = most recent timestamp) + results = client.zrevrange("GNSSFusion30Hz", 0, 0, withscores=True) + if results: + data = json.loads(results[0][0]) + return { + "lat_deg": data.get("lat_deg"), + "lon_deg": data.get("lon_deg"), + "alt_m": data.get("alt_m"), + "unix_milliseconds": int(data.get("unix_milliseconds", 0)), + "hdop": data.get("hdop"), + "num_satellites": data.get("num_satellites", 0), + } + except (redis.RedisError, json.JSONDecodeError): + pass + return None + + +def has_gps_lock(): + """Check if we have a valid GPS lock.""" + gnss = get_latest_gnss() + return gnss is not None and gnss.get("num_satellites", 0) >= 4 diff --git a/adacam_api/routes/__init__.py b/adacam_api/routes/__init__.py new file mode 100644 index 0000000..5809063 --- /dev/null +++ b/adacam_api/routes/__init__.py @@ -0,0 +1 @@ +"""API route blueprints.""" diff --git a/adacam_api/routes/frames.py b/adacam_api/routes/frames.py new file mode 100644 index 0000000..f2ef8e9 --- /dev/null +++ b/adacam_api/routes/frames.py @@ -0,0 +1,22 @@ +"""Recording frame endpoints.""" +import os +import glob +from flask import Blueprint, jsonify + +bp = Blueprint("frames", __name__, url_prefix="/api/1/recording") + +FRAMES_DIR = "/tmp/recording/pics" + + +@bp.route("/frames/latest", methods=["GET"]) +def latest_frame(): + """Get path to most recent frame file.""" + if not os.path.isdir(FRAMES_DIR): + return jsonify({"error": "No frames directory"}), 404 + + files = glob.glob(os.path.join(FRAMES_DIR, "*")) + if not files: + return jsonify({"error": "No frames available"}), 404 + + latest = max(files, key=os.path.getmtime) + return jsonify({"path": latest}) diff --git a/adacam_api/routes/gnss.py b/adacam_api/routes/gnss.py new file mode 100644 index 0000000..916491f --- /dev/null +++ b/adacam_api/routes/gnss.py @@ -0,0 +1,14 @@ +"""GNSS/GPS endpoints.""" +from flask import Blueprint, jsonify +from .. import redis_client + +bp = Blueprint("gnss", __name__, url_prefix="/api/1/gnssConcise") + + +@bp.route("/latestValid", methods=["GET"]) +def latest_valid(): + """Get current GPS fix from Redis.""" + gnss = redis_client.get_latest_gnss() + if gnss: + return jsonify(gnss) + return jsonify({"error": "No valid GPS fix"}), 503 diff --git a/adacam_api/routes/landmarks.py b/adacam_api/routes/landmarks.py new file mode 100644 index 0000000..d75a3d2 --- /dev/null +++ b/adacam_api/routes/landmarks.py @@ -0,0 +1,26 @@ +"""Landmark detection endpoints.""" +from flask import Blueprint, jsonify, request +from .. import db, forwarder + +bp = Blueprint("landmarks", __name__, url_prefix="/api/1/landmarks") + + +@bp.route("/last/", methods=["GET"]) +def get_last(n): + """Get last N detections.""" + n = min(n, 1000) # Cap at 1000 + landmarks = db.get_last_landmarks(n) + return jsonify(landmarks) + + +@bp.route("", methods=["POST"]) +def ingest(): + """Ingest a new detection from camera pipeline.""" + data = request.get_json() + if not data or "class_label" not in data: + return jsonify({"error": "Missing class_label"}), 400 + + db.insert_landmark(data) + forwarder.forward_detection(data) + + return jsonify({"status": "ok"}) diff --git a/adacam_api/routes/status.py b/adacam_api/routes/status.py new file mode 100644 index 0000000..cf0560f --- /dev/null +++ b/adacam_api/routes/status.py @@ -0,0 +1,28 @@ +"""Device status and info endpoints.""" +import time +from flask import Blueprint, jsonify +from .. import config, redis_client + +bp = Blueprint("status", __name__, url_prefix="/api/1") + +_start_time = time.time() + + +@bp.route("/status", methods=["GET"]) +def status(): + """Device status: firmware, uptime, GPS lock, camera status.""" + return jsonify({ + "firmware_version": config.FIRMWARE_VERSION, + "uptime_seconds": int(time.time() - _start_time), + "gps_lock": redis_client.has_gps_lock(), + "camera_status": "active", # TODO: check camera pipeline + }) + + +@bp.route("/deviceinfo", methods=["GET"]) +def deviceinfo(): + """Device identity.""" + return jsonify({ + "device_id": config.get("device_id"), + "firmware_version": config.FIRMWARE_VERSION, + }) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..919384b --- /dev/null +++ b/install.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# adacam-api installer for Hivemapper Bee device +set -e + +INSTALL_DIR="/opt/adacam" +DATA_DIR="/data/adacam" + +echo "[*] Installing adacam-api..." + +# Create directories +mkdir -p "$INSTALL_DIR" "$DATA_DIR" + +# Copy files +cp -r adacam_api main.py requirements.txt "$INSTALL_DIR/" + +# Install Python dependencies +pip3 install --no-cache-dir -r "$INSTALL_DIR/requirements.txt" + +# Install systemd service +cp systemd/adacam-api.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable adacam-api + +# Generate device ID if not present +if [ ! -f "$DATA_DIR/config.json" ]; then + python3 -c "from adacam_api import config; config.load()" + echo "[*] Generated new device ID" +fi + +echo "[*] Starting adacam-api..." +systemctl restart adacam-api +systemctl status adacam-api --no-pager + +echo "[+] Installation complete. API running on port 5000" diff --git a/main.py b/main.py new file mode 100755 index 0000000..15703e2 --- /dev/null +++ b/main.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""adacam-api entry point.""" +from adacam_api.app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6f89858 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=2.0 +redis>=4.0 +requests>=2.25 diff --git a/systemd/adacam-api.service b/systemd/adacam-api.service new file mode 100644 index 0000000..35c3c3e --- /dev/null +++ b/systemd/adacam-api.service @@ -0,0 +1,15 @@ +[Unit] +Description=AdaCam API Service +After=network.target redis.service +Wants=redis.service + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /opt/adacam/main.py +WorkingDirectory=/opt/adacam +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target