"""Primary LoraTester server: REST API + web UI.""" import time from pathlib import Path from flask import Flask, jsonify, request, send_from_directory from flask_cors import CORS from core.auth import ANDROID_CLIENT_HEADER, is_android_client from core.config import HOST, PORT from core.models import ChatIn, TelemetryIn from core import storage from core.telemetry_body import telemetry_from_body STATIC_DIR = Path(__file__).resolve().parent / "static" app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="/static") CORS(app) storage.init_db() @app.route("/") def index(): resp = send_from_directory(STATIC_DIR, "index.html") resp.headers["Cache-Control"] = "no-store" return resp @app.post("/api/telemetry") def post_telemetry(): if not is_android_client(request.headers): return jsonify({ "error": "telemetry only from Android app", "hint": f"send header {ANDROID_CLIENT_HEADER}: android", }), 403 body = request.get_json(force=True, silent=True) or {} device_id = body.get("device_id") if not device_id: return jsonify({"error": "device_id required"}), 400 try: data = telemetry_from_body({**body, "device_id": device_id}) return jsonify(storage.record_telemetry(data)) except ValueError as e: return jsonify({"error": str(e)}), 400 @app.get("/api/devices") def get_devices(): return jsonify(storage.list_devices()) @app.get("/api/telemetry") def get_telemetry_history(): device_id = request.args.get("device_id") limit = int(request.args.get("limit", 100)) since = _float_or_none(request.args.get("since")) until = _float_or_none(request.args.get("until")) role = request.args.get("role") return jsonify(storage.get_telemetry(device_id, limit, since, until, role)) @app.get("/api/stats/history") def get_stats_history(): device_id = request.args.get("device_id") if not device_id: return jsonify({"error": "device_id required"}), 400 limit = int(request.args.get("limit", 50)) since = _float_or_none(request.args.get("since")) until = _float_or_none(request.args.get("until")) role = request.args.get("role") return jsonify(storage.get_telemetry(device_id, limit, since, until, role)) @app.post("/api/tracks/start") def tracks_start(): if not is_android_client(request.headers): return jsonify({"error": "Android only"}), 403 body = request.get_json(force=True, silent=True) or {} device_id = body.get("device_id") if not device_id: return jsonify({"error": "device_id required"}), 400 try: return jsonify(storage.start_track(str(device_id), body.get("label"))) except ValueError as e: return jsonify({"error": str(e)}), 400 @app.post("/api/tracks//points") def tracks_points(track_id: int): if not is_android_client(request.headers): return jsonify({"error": "Android only"}), 403 body = request.get_json(force=True, silent=True) or {} points = body.get("points") or [] try: return jsonify(storage.add_track_points(track_id, points)) except ValueError as e: return jsonify({"error": str(e)}), 400 @app.post("/api/tracks//finish") def tracks_finish(track_id: int): if not is_android_client(request.headers): return jsonify({"error": "Android only"}), 403 try: return jsonify(storage.finish_track(track_id)) except ValueError as e: return jsonify({"error": str(e)}), 400 @app.get("/api/tracks") def tracks_list(): device_id = request.args.get("device_id") limit = int(request.args.get("limit", 50)) return jsonify(storage.list_tracks(device_id, limit)) @app.get("/api/tracks/") def tracks_get(track_id: int): try: return jsonify(storage.get_track(track_id)) except ValueError as e: return jsonify({"error": str(e)}), 404 @app.post("/api/chat") def post_chat(): body = request.get_json(force=True, silent=True) or {} device_id = body.get("device_id") text = (body.get("text") or "").strip() if not device_id or not text: return jsonify({"error": "device_id and text required"}), 400 data = ChatIn( device_id=str(device_id), text=text, ts=_float_or_none(body.get("ts")), ) return jsonify(storage.add_chat(data)) @app.get("/api/chat") def get_chat(): since = float(request.args.get("since", 0)) limit = int(request.args.get("limit", 200)) return jsonify(storage.get_chat(since, limit)) @app.post("/api/commands") def post_command(): body = request.get_json(force=True, silent=True) or {} from_id = body.get("from_device_id") to_id = body.get("to_device_id") kind = body.get("kind") if not from_id or not to_id or not kind: return jsonify({"error": "from_device_id, to_device_id, kind required"}), 400 try: return jsonify( storage.enqueue_command( str(from_id), str(to_id), str(kind), body.get("payload") ) ) except ValueError as e: return jsonify({"error": str(e)}), 400 @app.get("/api/commands/pending") def commands_pending(): if not is_android_client(request.headers): return jsonify({"error": "Android only"}), 403 device_id = request.args.get("device_id") if not device_id: return jsonify({"error": "device_id required"}), 400 limit = int(request.args.get("limit", 20)) try: return jsonify(storage.poll_pending_commands(str(device_id), limit)) except ValueError as e: return jsonify({"error": str(e)}), 400 @app.get("/api/commands") def commands_list(): to_device_id = request.args.get("to_device_id") limit = int(request.args.get("limit", 50)) return jsonify(storage.list_commands(to_device_id, limit)) @app.post("/api/paired-tracks/start") def paired_tracks_start(): body = request.get_json(force=True, silent=True) or {} initiator = body.get("initiator") or ( body.get("device_id") if is_android_client(request.headers) else storage.WEB_SENDER_ID ) device_ids = body.get("device_ids") try: return jsonify( storage.start_paired_track( device_ids if isinstance(device_ids, list) else None, str(initiator), ) ) except ValueError as e: return jsonify({"error": str(e)}), 400 @app.get("/api/paired-tracks/active") def paired_tracks_active(): session = storage.get_active_paired_track() return jsonify({"active": session is not None, "session": session}) @app.post("/api/paired-tracks/ack") def paired_tracks_ack(): if not is_android_client(request.headers): return jsonify({"error": "Android only"}), 403 body = request.get_json(force=True, silent=True) or {} session_id = body.get("session_id") device_id = body.get("device_id") track_id = body.get("track_id") if session_id is None or not device_id or track_id is None: return jsonify({"error": "session_id, device_id, track_id required"}), 400 try: return jsonify( storage.ack_paired_track(int(session_id), str(device_id), int(track_id)) ) except ValueError as e: return jsonify({"error": str(e)}), 400 @app.post("/api/paired-tracks/cancel") def paired_tracks_cancel(): body = request.get_json(force=True, silent=True) or {} session_id = body.get("session_id") try: sid = int(session_id) if session_id is not None else None return jsonify(storage.cancel_paired_track(sid)) except ValueError as e: return jsonify({"error": str(e)}), 400 @app.post("/api/elevation/profile") def elevation_profile(): from core.elevation import build_elevation_profile body = request.get_json(force=True, silent=True) or {} points = body.get("points") or [] step_m = body.get("step_m", 10) try: step = float(step_m) except (TypeError, ValueError): step = 10.0 return jsonify(build_elevation_profile(points, step)) @app.get("/api/tracks//elevation-profile") def track_elevation_profile(track_id: int): from core.elevation import build_elevation_profile step_m = request.args.get("step_m", 10, type=float) try: track = storage.get_track(track_id) except ValueError as e: return jsonify({"error": str(e)}), 404 points = track.get("points") or [] return jsonify(build_elevation_profile(points, step_m or 10.0)) @app.get("/api/elevation/nearest-hill") def elevation_nearest_hill(): from core.elevation import find_nearest_hill lat = request.args.get("lat", type=float) lon = request.args.get("lon", type=float) if lat is None or lon is None: return jsonify({"ok": False, "error": "lat and lon required"}), 400 radius_m = request.args.get("radius_m", 5000, type=float) step_m = request.args.get("step_m", 300, type=float) min_prominence_m = request.args.get("min_prominence_m", 8, type=float) return jsonify(find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m)) @app.get("/api/health") def health(): from core.elevation import elevation_status status = storage.db_status() return jsonify( {"ok": status["db_ok"], "ts": time.time(), **status, **elevation_status()} ) def _float_or_none(value): if value is None or value == "": return None try: return float(value) except (TypeError, ValueError): return None if __name__ == "__main__": app.run(host=HOST, port=PORT, debug=True)