Initial commit: adacam-api v1.0.0

Clean Python Flask replacement for odc-api (434k lines Node.js → ~350 lines Python)
- GET /api/1/landmarks/last/{N} - last N detections from SQLite
- POST /api/1/landmarks - ingest detections + forward to AdaMaps
- GET /api/1/gnssConcise/latestValid - GPS fix from Redis
- GET /api/1/status - device status
- GET /api/1/deviceinfo - device identity
- GET /api/1/recording/frames/latest - latest frame path

No /api/1/cmd - that's the CVE, it's gone.

Includes:
- SQLite for local storage + offline queue
- Background thread for AdaMaps retry
- systemd service unit
- install.sh for device deployment
This commit is contained in:
kayos 2026-03-14 08:13:04 -07:00
parent b05c0e3d03
commit 37aefb84c8
17 changed files with 507 additions and 1 deletions

2
adacam_api/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""adacam-api: Clean replacement for odc-api."""
__version__ = "1.0.0"

24
adacam_api/app.py Normal file
View file

@ -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

52
adacam_api/config.py Normal file
View file

@ -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)

88
adacam_api/db.py Normal file
View file

@ -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()

83
adacam_api/forwarder.py Normal file
View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
"""API route blueprints."""

View file

@ -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})

14
adacam_api/routes/gnss.py Normal file
View file

@ -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

View file

@ -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/<int:n>", 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"})

View file

@ -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,
})