Files
intercept/routes/bt_locate.py
T
James Smith 96172ca593 style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:48:11 +01:00

321 lines
9.7 KiB
Python

"""
BT Locate — Bluetooth SAR Device Location Flask Blueprint.
Provides endpoints for managing locate sessions, streaming detection events,
and retrieving GPS-tagged signal trails.
"""
from __future__ import annotations
import logging
from collections.abc import Generator
from flask import Blueprint, Response, jsonify, request
from utils.bluetooth.irk_extractor import get_paired_irks
from utils.bt_locate import (
Environment,
LocateTarget,
get_locate_session,
resolve_rpa,
start_locate_session,
stop_locate_session,
)
from utils.responses import api_error
from utils.sse import format_sse
logger = logging.getLogger("intercept.bt_locate")
bt_locate_bp = Blueprint("bt_locate", __name__, url_prefix="/bt_locate")
@bt_locate_bp.route("/start", methods=["POST"])
def start_session():
"""
Start a locate session.
Request JSON:
- mac_address: Target MAC address (optional)
- name_pattern: Target name substring (optional)
- irk_hex: Identity Resolving Key hex string (optional)
- device_id: Device ID from Bluetooth scanner (optional)
- device_key: Stable device key from Bluetooth scanner (optional)
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
- known_name: Hand-off device name (optional)
- known_manufacturer: Hand-off manufacturer (optional)
- last_known_rssi: Hand-off last RSSI (optional)
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
Returns:
JSON with session status.
"""
data = request.get_json() or {}
# Build target
target = LocateTarget(
mac_address=data.get("mac_address"),
name_pattern=data.get("name_pattern"),
irk_hex=data.get("irk_hex"),
device_id=data.get("device_id"),
device_key=data.get("device_key"),
fingerprint_id=data.get("fingerprint_id"),
known_name=data.get("known_name"),
known_manufacturer=data.get("known_manufacturer"),
last_known_rssi=data.get("last_known_rssi"),
)
# At least one identifier required
if not any(
[
target.mac_address,
target.name_pattern,
target.irk_hex,
target.device_id,
target.device_key,
target.fingerprint_id,
]
):
return api_error(
"At least one target identifier required "
"(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)",
400,
)
# Parse environment
env_str = data.get("environment", "OUTDOOR").upper()
try:
environment = Environment[env_str]
except KeyError:
return api_error(f"Invalid environment: {env_str}", 400)
custom_exponent = data.get("custom_exponent")
if custom_exponent is not None:
try:
custom_exponent = float(custom_exponent)
except (ValueError, TypeError):
return api_error("custom_exponent must be a number", 400)
# Fallback coordinates when GPS is unavailable (from user settings)
fallback_lat = None
fallback_lon = None
if data.get("fallback_lat") is not None and data.get("fallback_lon") is not None:
try:
fallback_lat = float(data["fallback_lat"])
fallback_lon = float(data["fallback_lon"])
except (ValueError, TypeError):
pass
logger.info(
f"Starting locate session: target={target.to_dict()}, "
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
)
try:
session = start_locate_session(target, environment, custom_exponent, fallback_lat, fallback_lon)
except RuntimeError as exc:
logger.warning(f"Unable to start BT Locate session: {exc}")
return api_error("Bluetooth scanner could not be started. Check adapter permissions/capabilities.", 503)
except Exception as exc:
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
return api_error("Failed to start locate session", 500)
return jsonify(
{
"status": "started",
"session": session.get_status(),
}
)
@bt_locate_bp.route("/stop", methods=["POST"])
def stop_session():
"""Stop the active locate session."""
session = get_locate_session()
if not session:
return jsonify({"status": "no_session"})
stop_locate_session()
return jsonify({"status": "stopped"})
@bt_locate_bp.route("/status", methods=["GET"])
def get_status():
"""Get locate session status."""
session = get_locate_session()
if not session:
return jsonify(
{
"active": False,
"target": None,
}
)
include_debug = str(request.args.get("debug", "")).lower() in ("1", "true", "yes")
return jsonify(session.get_status(include_debug=include_debug))
@bt_locate_bp.route("/trail", methods=["GET"])
def get_trail():
"""Get detection trail data."""
session = get_locate_session()
if not session:
return jsonify({"trail": [], "gps_trail": []})
return jsonify(
{
"trail": session.get_trail(),
"gps_trail": session.get_gps_trail(),
}
)
@bt_locate_bp.route("/stream", methods=["GET"])
def stream_detections():
"""SSE stream of detection events."""
def event_generator() -> Generator[str, None, None]:
while True:
# Re-fetch session each iteration in case it changes
s = get_locate_session()
if not s:
yield format_sse({"type": "session_ended"}, event="session_ended")
return
try:
event = s.event_queue.get(timeout=2.0)
yield format_sse(event, event="detection")
except Exception:
yield format_sse({}, event="ping")
return Response(
event_generator(),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@bt_locate_bp.route("/resolve_rpa", methods=["POST"])
def test_resolve_rpa():
"""
Test if an IRK resolves to a given address.
Request JSON:
- irk_hex: 16-byte IRK as hex string
- address: BLE address string
Returns:
JSON with resolution result.
"""
data = request.get_json() or {}
irk_hex = data.get("irk_hex", "")
address = data.get("address", "")
if not irk_hex or not address:
return api_error("irk_hex and address are required", 400)
try:
irk = bytes.fromhex(irk_hex)
except ValueError:
return api_error("Invalid IRK hex string", 400)
if len(irk) != 16:
return api_error("IRK must be exactly 16 bytes (32 hex characters)", 400)
result = resolve_rpa(irk, address)
return jsonify(
{
"resolved": result,
"irk_hex": irk_hex,
"address": address,
}
)
@bt_locate_bp.route("/environment", methods=["POST"])
def set_environment():
"""Update the environment on the active session."""
session = get_locate_session()
if not session:
return api_error("no active session", 400)
data = request.get_json() or {}
env_str = data.get("environment", "").upper()
try:
environment = Environment[env_str]
except KeyError:
return api_error(f"Invalid environment: {env_str}", 400)
custom_exponent = data.get("custom_exponent")
if custom_exponent is not None:
try:
custom_exponent = float(custom_exponent)
except (ValueError, TypeError):
custom_exponent = None
session.set_environment(environment, custom_exponent)
return jsonify(
{
"status": "updated",
"environment": environment.name,
"path_loss_exponent": session.estimator.n,
}
)
@bt_locate_bp.route("/debug", methods=["GET"])
def debug_matching():
"""Debug endpoint showing scanner devices and match results."""
session = get_locate_session()
if not session:
return api_error("no session")
scanner = session._scanner
if not scanner:
return api_error("no scanner")
devices = scanner.get_devices(max_age_seconds=30)
return jsonify(
{
"target": session.target.to_dict(),
"device_count": len(devices),
"devices": [
{
"device_id": d.device_id,
"address": d.address,
"name": d.name,
"rssi": d.rssi_current,
"matches": session.target.matches(d),
}
for d in devices
],
}
)
@bt_locate_bp.route("/paired_irks", methods=["GET"])
def paired_irks():
"""Return paired Bluetooth devices that have IRKs."""
try:
devices = get_paired_irks()
except Exception as e:
logger.exception("Failed to read paired IRKs")
return jsonify({"devices": [], "error": str(e)})
return jsonify({"devices": devices})
@bt_locate_bp.route("/clear_trail", methods=["POST"])
def clear_trail():
"""Clear the detection trail."""
session = get_locate_session()
if not session:
return jsonify({"status": "no_session"})
session.clear_trail()
return jsonify({"status": "cleared"})