From b628a5f751c626f336028df8f84adcb5a6338797 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 20 Feb 2026 17:02:16 +0000 Subject: [PATCH 01/14] Add drone ops mode and retire DMR support --- Dockerfile | 23 - app.py | 63 +- config.py | 7 +- docs/PROFESSIONAL_OPS_MVP.md | 192 ++++ docs/UI_GUIDE.md | 4 +- routes/__init__.py | 22 +- routes/dmr.py | 753 ------------- routes/drone_ops.py | 431 +++++++ routes/subghz.py | 19 +- setup.sh | 123 +- static/css/index.css | 12 + static/css/modes/droneops.css | 498 +++++++++ static/js/components/signal-guess.js | 21 +- static/js/core/command-palette.js | 1 - static/js/core/run-state.js | 4 +- static/js/modes/dmr.js | 852 -------------- static/js/modes/droneops.js | 1418 ++++++++++++++++++++++++ templates/index.html | 179 +-- templates/partials/modes/dmr.html | 114 -- templates/partials/modes/droneops.html | 152 +++ templates/partials/nav.html | 2 + tests/test_dmr.py | 311 ------ tests/test_drone_ops_policy.py | 36 + tests/test_drone_ops_remote_id.py | 60 + tests/test_drone_ops_routes.py | 228 ++++ tests/test_routes.py | 11 - utils/analytics.py | 47 +- utils/authz.py | 74 ++ utils/database.py | 1000 ++++++++++++++++- utils/drone/__init__.py | 10 + utils/drone/detector.py | 305 +++++ utils/drone/policy.py | 11 + utils/drone/remote_id.py | 121 ++ utils/drone/service.py | 621 +++++++++++ utils/event_pipeline.py | 7 + utils/signal_guess.py | 24 +- 36 files changed, 5338 insertions(+), 2418 deletions(-) create mode 100644 docs/PROFESSIONAL_OPS_MVP.md delete mode 100644 routes/dmr.py create mode 100644 routes/drone_ops.py create mode 100644 static/css/modes/droneops.css delete mode 100644 static/js/modes/dmr.js create mode 100644 static/js/modes/droneops.js delete mode 100644 templates/partials/modes/dmr.html create mode 100644 templates/partials/modes/droneops.html delete mode 100644 tests/test_dmr.py create mode 100644 tests/test_drone_ops_policy.py create mode 100644 tests/test_drone_ops_remote_id.py create mode 100644 tests/test_drone_ops_routes.py create mode 100644 utils/authz.py create mode 100644 utils/drone/__init__.py create mode 100644 utils/drone/detector.py create mode 100644 utils/drone/policy.py create mode 100644 utils/drone/remote_id.py create mode 100644 utils/drone/service.py diff --git a/Dockerfile b/Dockerfile index 6dacb09..fb48ca8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -94,7 +94,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libpulse-dev \ libfftw3-dev \ liblapack-dev \ - libcodec2-dev \ libglib2.0-dev \ libxml2-dev \ # Build dump1090 @@ -199,27 +198,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && go install github.com/bemasher/rtlamr@latest \ && cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \ && rm -rf /usr/local/go /tmp/gopath \ - # Build mbelib (required by DSD) - && cd /tmp \ - && git clone https://github.com/lwvmobile/mbelib.git \ - && cd mbelib \ - && (git checkout ambe_tones || true) \ - && mkdir build && cd build \ - && cmake .. \ - && make -j$(nproc) \ - && make install \ - && ldconfig \ - && rm -rf /tmp/mbelib \ - # Build DSD-FME (Digital Speech Decoder for DMR/P25) - && cd /tmp \ - && git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \ - && cd dsd-fme \ - && mkdir build && cd build \ - && cmake .. \ - && make -j$(nproc) \ - && make install \ - && ldconfig \ - && rm -rf /tmp/dsd-fme \ # Cleanup build tools to reduce image size # libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx && apt-get remove -y \ @@ -247,7 +225,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libpulse-dev \ libfftw3-dev \ liblapack-dev \ - libcodec2-dev \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* diff --git a/app.py b/app.py index f92caea..cfb6d95 100644 --- a/app.py +++ b/app.py @@ -177,15 +177,9 @@ dsc_rtl_process = None dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) dsc_lock = threading.Lock() -# DMR / Digital Voice -dmr_process = None -dmr_rtl_process = None -dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) -dmr_lock = threading.Lock() - -# TSCM (Technical Surveillance Countermeasures) -tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) -tscm_lock = threading.Lock() +# TSCM (Technical Surveillance Countermeasures) +tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +tscm_lock = threading.Lock() # SubGHz Transceiver (HackRF) subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) @@ -652,21 +646,11 @@ def export_bluetooth() -> Response: }) -def _get_subghz_active() -> bool: - """Check if SubGHz manager has an active process.""" - try: - from utils.subghz import get_subghz_manager - return get_subghz_manager().active_mode != 'idle' - except Exception: - return False - - -def _get_dmr_active() -> bool: - """Check if Digital Voice decoder has an active process.""" +def _get_subghz_active() -> bool: + """Check if SubGHz manager has an active process.""" try: - from routes import dmr as dmr_module - proc = dmr_module.dmr_dsd_process - return bool(dmr_module.dmr_running and proc and proc.poll() is None) + from utils.subghz import get_subghz_manager + return get_subghz_manager().active_mode != 'idle' except Exception: return False @@ -746,7 +730,6 @@ def health_check() -> Response: 'wifi': wifi_active, 'bluetooth': bt_active, 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), - 'dmr': _get_dmr_active(), 'subghz': _get_subghz_active(), }, 'data': { @@ -761,12 +744,11 @@ def health_check() -> Response: @app.route('/killall', methods=['POST']) -def kill_all() -> Response: +def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global vdl2_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process - global dmr_process, dmr_rtl_process # Import adsb and ais modules to reset their state from routes import adsb as adsb_module @@ -778,7 +760,7 @@ def kill_all() -> Response: 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', 'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher', - 'hcitool', 'bluetoothctl', 'satdump', 'dsd', + 'hcitool', 'bluetoothctl', 'satdump', 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', 'hackrf_transfer', 'hackrf_sweep' ] @@ -828,12 +810,7 @@ def kill_all() -> Response: dsc_process = None dsc_rtl_process = None - # Reset DMR state - with dmr_lock: - dmr_process = None - dmr_rtl_process = None - - # Reset Bluetooth state (legacy) + # Reset Bluetooth state (legacy) with bt_lock: if bt_process: try: @@ -853,16 +830,16 @@ def kill_all() -> Response: except Exception: pass - # Reset SubGHz state - try: - from utils.subghz import get_subghz_manager - get_subghz_manager().stop_all() - except Exception: - pass - - # Clear SDR device registry - with sdr_device_registry_lock: - sdr_device_registry.clear() + # Reset SubGHz state + try: + from utils.subghz import get_subghz_manager + get_subghz_manager().stop_all() + except Exception: + pass + + # Clear SDR device registry + with sdr_device_registry_lock: + sdr_device_registry.clear() return jsonify({'status': 'killed', 'processes': killed}) diff --git a/config.py b/config.py index f8d6287..6b94432 100644 --- a/config.py +++ b/config.py @@ -99,17 +99,14 @@ CHANGELOG = [ "Pure Python SSTV decoder replacing broken slowrx dependency", "Real-time signal scope for pager, sensor, and SSTV modes", "USB-level device probe to prevent cryptic rtl_fm crashes", - "DMR dsd-fme protocol fixes, tuning controls, and state sync", - "SDR device lock-up fix from unreleased device registry on crash", + "SDR device lock-up fix from unreleased device registry on crash", ] }, { "version": "2.14.0", "date": "February 2026", "highlights": [ - "DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme", - "DMR visual synthesizer with event-driven spring-physics bars", - "HF SSTV general mode with predefined shortwave frequencies", + "HF SSTV general mode with predefined shortwave frequencies", "WebSDR integration for remote HF/shortwave listening", "Listening Post signal scanner and audio pipeline improvements", "TSCM sweep resilience, WiFi detection, and correlation fixes", diff --git a/docs/PROFESSIONAL_OPS_MVP.md b/docs/PROFESSIONAL_OPS_MVP.md new file mode 100644 index 0000000..f687805 --- /dev/null +++ b/docs/PROFESSIONAL_OPS_MVP.md @@ -0,0 +1,192 @@ +# Professional Ops + Drone Capability Matrix (MVP) + +This plan enables full professional capability (passive, active testing, and evidence workflows) while keeping strict authorization, approvals, and auditability. + +## 1) Capability Matrix (Feature Availability) + +| Capability | Passive (Observe) | Active (Controlled Test) | Evidence / Audit | Reuse in Current Codebase | MVP Build Additions | +|---|---|---|---|---|---| +| Drone detection and classification | Detect likely drone entities from WiFi/BLE/RF metadata | Trigger controlled test sessions to validate detector quality | Store detections with source, confidence, and timestamps | `/wifi/v2/*`, `/api/bluetooth/*`, `/subghz/*`, `utils/tscm/detector.py` | New detector adapters in `utils/drone/` and aggregation API | +| Remote ID intelligence | Parse and display drone/operator identifiers and telemetry | Run controlled replay/simulation inputs for validation | Persist decoded records and parser provenance | `routes/wifi_v2.py`, `routes/bluetooth_v2.py`, `utils/event_pipeline.py` | `utils/drone/remote_id.py`, `/drone-ops/remote-id/*` endpoints | +| C2 and video link analysis | Identify likely C2/video channels and protocol patterns | Controlled injection/exercise mode for authorized ranges | Save link assessments and confidence history | `routes/listening_post.py`, `routes/subghz.py`, `static/js/components/signal-guess.js` | `utils/drone/link_analysis.py`, `/drone-ops/links/*` | +| Multi-agent geolocation | Estimate emitter/drone position from distributed observations | Active test mode to validate location solver error bounds | Capture estimate history with confidence ellipse | `routes/controller.py` location endpoints | `/drone-ops/geolocate/*` wrapper over controller location APIs | +| Operator/drone correlation | Correlate drone, operator, and nearby device candidates | Active proximity probes in controlled tests | Store correlation graph and confidence deltas | `/correlation`, `/analytics/target`, TSCM identity clustering | `utils/drone/correlation.py`, `/drone-ops/correlations/*` | +| Geofence and rules | Alert on zone breach and route/altitude anomalies | Zone-based active scenario testing | Immutable breach timeline and alert acknowledgements | `utils/geofence.py`, `/analytics/geofences`, `/alerts/*` | Drone-specific alert templates and rule presets | +| Incident command workflow | Build incident timeline from detections/alerts/tracks | Execute approved active tasks per playbook | Case package with linked artifacts and operator notes | TSCM cases and notes in `routes/tscm.py`, `utils/database.py` | Drone case types + incident board UI in Drone Ops mode | +| Replay and reporting | In-app replay of full incident event stream | Replay active test sessions for after-action review | Export signed package (JSONL + summary + hashes) | `/recordings/*`, Analytics replay UI, TSCM report generation | Evidence manifest + integrity hashing + chain-of-custody log | +| Active action controls | N/A | Full active actions available when armed/approved | Every action requires explicit reason and audit record | Existing active surfaces in `/wifi/deauth`, `/subghz/transmit` | Approval workflow (`two-person`) + command gate middleware | +| Access control and approvals | Role-based read access | Role + arming + approval enforced per action class | Full action audit trail with actor, approver, and case ID | `users.role` and session role in `app.py` | `utils/authz.py`, approval/audit tables, route decorators | + +## 2) Architecture Mapping to Existing Routes/UI + +## Backend routes to reuse directly + +- Detection feeds: + - `routes/wifi_v2.py` (`/wifi/v2/stream`, `/wifi/v2/networks`, `/wifi/v2/clients`, `/wifi/v2/probes`) + - `routes/bluetooth_v2.py` (`/api/bluetooth/stream`, `/api/bluetooth/devices`) + - `routes/subghz.py` (`/subghz/stream`, receive/decode status) +- Correlation and analytics: + - `routes/correlation.py` + - `routes/analytics.py` (`/analytics/target`, `/analytics/patterns`, `/analytics/geofences`) +- Multi-agent geolocation: + - `routes/controller.py` (`/controller/api/location/estimate`, `/controller/api/location/observe`, `/controller/api/location/all`) +- Alerts and recording: + - `routes/alerts.py` and `utils/alerts.py` + - `routes/recordings.py` and `utils/recording.py` + - Shared pipeline in `utils/event_pipeline.py` +- Case and reporting substrate: + - `routes/tscm.py` (`/tscm/cases`, `/tscm/report/pdf`, playbooks) + - TSCM persistence in `utils/database.py` + +## New backend module for MVP + +- Add `routes/drone_ops.py` with URL prefix `/drone-ops`. +- Add `utils/drone/` package: + - `aggregator.py` (normalize events from WiFi/BLE/RF) + - `remote_id.py` (parsers + confidence attribution) + - `link_analysis.py` (C2/video heuristics) + - `geo.py` (adapter to controller location estimation) + - `policy.py` (arming, approval, role checks) + +## Frontend integration points + +- Navigation: + - `templates/partials/nav.html` add `droneops` entry (Intel group). +- Mode panel: + - `templates/partials/modes/droneops.html` + - `static/js/modes/droneops.js` + - `static/css/modes/droneops.css` +- Main mode loader wiring: + - `templates/index.html` for new panel include + script/css registration. +- Cross-mode widgets: + - Reuse `static/js/components/signal-cards.js`, `timeline-heatmap.js`, `activity-timeline.js`. + +## 3) Proposed MVP API Surface (`/drone-ops`) + +| Endpoint | Method | Purpose | +|---|---|---| +| `/drone-ops/status` | `GET` | Health, active session, arming state, policy snapshot | +| `/drone-ops/session/start` | `POST` | Start passive/active Drone Ops session with metadata | +| `/drone-ops/session/stop` | `POST` | Stop session and finalize summary | +| `/drone-ops/detections` | `GET` | Current detection list with filters | +| `/drone-ops/stream` | `GET` (SSE) | Unified live stream (detections, tracks, alerts, approvals) | +| `/drone-ops/remote-id/decode` | `POST` | Decode submitted frame payload (test and replay support) | +| `/drone-ops/tracks` | `GET` | Track list and selected track history | +| `/drone-ops/geolocate/estimate` | `POST` | Request geolocation estimate from distributed observations | +| `/drone-ops/correlations` | `GET` | Drone/operator/device correlation graph | +| `/drone-ops/incidents` | `POST` / `GET` | Create/list incidents | +| `/drone-ops/incidents/` | `GET` / `PUT` | Incident detail and status updates | +| `/drone-ops/incidents//artifacts` | `POST` | Attach notes, detections, tracks, alerts, recordings | +| `/drone-ops/actions/arm` | `POST` | Arm active action plane with reason + case/incident ID | +| `/drone-ops/actions/request` | `POST` | Submit active action requiring approval policy | +| `/drone-ops/actions/approve/` | `POST` | Secondary approval (if required) | +| `/drone-ops/actions/execute/` | `POST` | Execute approved action via gated dispatcher | + +## 4) Data Model Additions (SQLite, MVP) + +Add to `utils/database.py`: + +- `drone_sessions` + - `id`, `started_at`, `stopped_at`, `mode` (`passive`/`active`), `operator`, `metadata` +- `drone_detections` + - `id`, `session_id`, `first_seen`, `last_seen`, `source`, `identifier`, `confidence`, `payload_json` +- `drone_tracks` + - `id`, `detection_id`, `timestamp`, `lat`, `lon`, `altitude_m`, `speed_mps`, `heading_deg`, `quality` +- `drone_correlations` + - `id`, `drone_identifier`, `operator_identifier`, `method`, `confidence`, `evidence_json`, `created_at` +- `drone_incidents` + - `id`, `title`, `status`, `severity`, `opened_by`, `opened_at`, `closed_at`, `summary` +- `drone_incident_artifacts` + - `id`, `incident_id`, `artifact_type`, `artifact_ref`, `added_by`, `added_at`, `metadata` +- `action_requests` + - `id`, `incident_id`, `action_type`, `requested_by`, `requested_at`, `status`, `payload_json` +- `action_approvals` + - `id`, `request_id`, `approved_by`, `approved_at`, `decision`, `notes` +- `action_audit_log` + - `id`, `request_id`, `event_type`, `actor`, `timestamp`, `details_json` +- `evidence_manifests` + - `id`, `incident_id`, `created_at`, `hash_algo`, `manifest_json`, `signature` + +Note: existing `recording_sessions` and `alert_events` remain the primary event substrate; drone tables link to those records for case assembly. + +## 5) Authorization and Arming Model (All Features Available) + +All features remain implemented and reachable in code. Execution path depends on policy state. + +- Roles (extend `users.role` semantics): + - `viewer`: read-only + - `analyst`: passive + evidence operations + - `operator`: passive + active request submission + - `supervisor`: approval authority + - `admin`: full control + policy management +- Active command state machine: + - `DISARMED` (default): active commands denied + - `ARMED` (time-bound): request creation allowed with incident ID and reason + - `APPROVED`: dual-approval actions executable + - `EXECUTED`: immutable audit records written +- Enforcement: + - Add decorators in `utils/authz.py`: + - `@require_role(...)` + - `@require_armed` + - `@require_two_person_approval` + +## 6) MVP Delivery Plan (6 Weeks) + +## Phase 0 (Week 1): Scaffolding + +- Add `routes/drone_ops.py` blueprint and register in `routes/__init__.py`. +- Add `utils/drone/` package with aggregator skeleton. +- Add Drone Ops UI placeholders (`droneops.html`, `droneops.js`, `droneops.css`) and nav wiring. +- Add DB migration/create statements for drone tables. + +Exit criteria: +- Drone Ops mode loads, API health endpoint returns, empty-state UI renders. + +## Phase 1 (Weeks 1-2): Passive Drone Ops + +- Unified ingest from WiFi/BLE/SubGHz streams. +- Detection cards, live timeline, map tracks, geofence alert hooks. +- Remote ID decode endpoint and parser confidence model. +- Alert rule presets for drone intrusions. + +Exit criteria: +- Passive session can detect/classify, map updates in real time, alerts generated. + +## Phase 2 (Weeks 3-4): Correlation + Geolocation + Incident Workflow + +- Correlation graph (drone/operator/nearby device candidates). +- Multi-agent geolocation adapter using controller location endpoints. +- Incident creation and artifact linking. +- Replay integration using existing recordings/events APIs. + +Exit criteria: +- Operator can open incident, attach artifacts, replay key timeline, export preliminary report. + +## Phase 3 (Weeks 5-6): Active Actions + Evidence Integrity + +- Arming and approval workflows (`action_requests`, `action_approvals`). +- Active action dispatcher with role/policy checks. +- Evidence manifest export with hashes and chain-of-custody entries. +- Audit dashboards for who requested/approved/executed. + +Exit criteria: +- Active commands require approvals, all operations are auditable and exportable. + +## 7) Immediate Build Backlog (First Sprint) + +1. Create `routes/drone_ops.py` with `status`, `session/start`, `session/stop`, `stream`. +2. Add drone tables in `utils/database.py` and lightweight DAO helpers. +3. Add mode shell UI files and wire mode into `templates/index.html` and `templates/partials/nav.html`. +4. Implement aggregator wiring to existing WiFi/BT/SubGHz feeds via `utils/event_pipeline.py`. +5. Add `actions/arm` endpoint with role + incident requirement and TTL-based disarm. +6. Add baseline tests: + - `tests/test_drone_ops_routes.py` + - `tests/test_drone_ops_policy.py` + - `tests/test_drone_ops_remote_id.py` + +## 8) Risk Controls + +- False attribution risk: every correlation/geolocation output carries confidence and evidence provenance. +- Policy bypass risk: active command execution path only through centralized dispatcher. +- Evidence integrity risk: hash all exported artifacts and include manifest references. +- Operational safety risk: require explicit incident linkage and arming expiration. diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md index 8ba1a7b..05ddbfb 100644 --- a/docs/UI_GUIDE.md +++ b/docs/UI_GUIDE.md @@ -214,9 +214,7 @@ Extended base for full-screen dashboards (maps, visualizations). | `bt_locate` | BT Locate | | `analytics` | Analytics dashboard | | `spaceweather` | Space weather | -| `dmr` | DMR/P25 digital voice | - -### Navigation Groups +### Navigation Groups The navigation is organized into groups: - **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz diff --git a/routes/__init__.py b/routes/__init__.py index acbf3f2..e294894 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -29,14 +29,14 @@ def register_blueprints(app): from .sstv import sstv_bp from .weather_sat import weather_sat_bp from .sstv_general import sstv_general_bp - from .dmr import dmr_bp from .websdr import websdr_bp from .alerts import alerts_bp from .recordings import recordings_bp - from .subghz import subghz_bp - from .bt_locate import bt_locate_bp - from .analytics import analytics_bp - from .space_weather import space_weather_bp + from .subghz import subghz_bp + from .bt_locate import bt_locate_bp + from .analytics import analytics_bp + from .space_weather import space_weather_bp + from .drone_ops import drone_ops_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -65,16 +65,16 @@ def register_blueprints(app): app.register_blueprint(sstv_bp) # ISS SSTV decoder app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder app.register_blueprint(sstv_general_bp) # General terrestrial SSTV - app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR app.register_blueprint(alerts_bp) # Cross-mode alerts app.register_blueprint(recordings_bp) # Session recordings app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF) - app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking - app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard - app.register_blueprint(space_weather_bp) # Space weather monitoring - - # Initialize TSCM state with queue and lock from app + app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking + app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard + app.register_blueprint(space_weather_bp) # Space weather monitoring + app.register_blueprint(drone_ops_bp) # Drone Ops / professional workflow + + # Initialize TSCM state with queue and lock from app import app as app_module if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'): init_tscm_state(app_module.tscm_queue, app_module.tscm_lock) diff --git a/routes/dmr.py b/routes/dmr.py deleted file mode 100644 index 39750f1..0000000 --- a/routes/dmr.py +++ /dev/null @@ -1,753 +0,0 @@ -"""DMR / P25 / Digital Voice decoding routes.""" - -from __future__ import annotations - -import os -import queue -import re -import select -import shutil -import subprocess -import threading -import time -from datetime import datetime -from typing import Generator, Optional - -from flask import Blueprint, jsonify, request, Response - -import app as app_module -from utils.logging import get_logger -from utils.sse import sse_stream_fanout -from utils.event_pipeline import process_event -from utils.process import register_process, unregister_process -from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm -from utils.sdr import SDRFactory, SDRType -from utils.constants import ( - SSE_QUEUE_TIMEOUT, - SSE_KEEPALIVE_INTERVAL, - QUEUE_MAX_SIZE, -) - -logger = get_logger('intercept.dmr') - -dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr') - -# ============================================ -# GLOBAL STATE -# ============================================ - -dmr_rtl_process: Optional[subprocess.Popen] = None -dmr_dsd_process: Optional[subprocess.Popen] = None -dmr_thread: Optional[threading.Thread] = None -dmr_running = False -dmr_has_audio = False # True when ffmpeg available and dsd outputs audio -dmr_lock = threading.Lock() -dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) -dmr_active_device: Optional[int] = None - -# Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all -# active ffmpeg stdin sinks when streaming clients are connected. -# This prevents dsd-fme from blocking on stdout (which would also -# freeze stderr / text data output). -_ffmpeg_sinks: set[object] = set() -_ffmpeg_sinks_lock = threading.Lock() - -VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice'] - -# Classic dsd flags -_DSD_PROTOCOL_FLAGS = { - 'auto': [], - 'dmr': ['-fd'], - 'p25': ['-fp'], - 'nxdn': ['-fn'], - 'dstar': ['-fi'], - 'provoice': ['-fv'], -} - -# dsd-fme remapped several flags from classic DSD: -# -fs = DMR Simplex (NOT -fd which is D-STAR!), -# -fd = D-STAR (NOT DMR!), -fp = ProVoice (NOT P25), -# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1, -# -ft = XDMA multi-protocol decoder -_DSD_FME_PROTOCOL_FLAGS = { - 'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA - 'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!) - 'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme) - 'nxdn': ['-fn'], # NXDN96 - 'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!) - 'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv) -} - -# Modulation hints: force C4FM for protocols that use it, improving -# sync reliability vs letting dsd-fme auto-detect modulation type. -_DSD_FME_MODULATION = { - 'dmr': ['-mc'], # C4FM - 'nxdn': ['-mc'], # C4FM -} - -# ============================================ -# HELPERS -# ============================================ - - -def find_dsd() -> tuple[str | None, bool]: - """Find DSD (Digital Speech Decoder) binary. - - Checks for dsd-fme first (common fork), then falls back to dsd. - Returns (path, is_fme) tuple. - """ - path = shutil.which('dsd-fme') - if path: - return path, True - path = shutil.which('dsd') - if path: - return path, False - return None, False - - -def find_rtl_fm() -> str | None: - """Find rtl_fm binary.""" - return shutil.which('rtl_fm') - - -def find_rx_fm() -> str | None: - """Find SoapySDR rx_fm binary.""" - return shutil.which('rx_fm') - - -def find_ffmpeg() -> str | None: - """Find ffmpeg for audio encoding.""" - return shutil.which('ffmpeg') - - -def parse_dsd_output(line: str) -> dict | None: - """Parse a line of DSD stderr output into a structured event. - - Handles output from both classic ``dsd`` and ``dsd-fme`` which use - different formatting for talkgroup / source / voice frame lines. - """ - line = line.strip() - if not line: - return None - - # Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.) - # Only filter lines that are purely decorative — dsd-fme uses box-drawing - # characters (│, ─) as column separators in DATA lines, so we must not - # discard lines that also contain alphanumeric content. - stripped_of_box = re.sub(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░\s]', '', line) - if not stripped_of_box: - return None - if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line): - return None - - ts = datetime.now().strftime('%H:%M:%S') - - # Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1" - sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line) - if sync_match: - return { - 'type': 'sync', - 'protocol': sync_match.group(1).strip(), - 'timestamp': ts, - } - - # Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …" - # is captured as a call event rather than a bare slot event. - # Classic dsd: "TG: 12345 Src: 67890" - # dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890" - # "TGT: 12345 | SRC: 67890" (pipe-delimited variant) - tg_match = re.search( - r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE - ) - if tg_match: - result = { - 'type': 'call', - 'talkgroup': int(tg_match.group(1)), - 'source_id': int(tg_match.group(2)), - 'timestamp': ts, - } - # Extract slot if present on the same line - slot_inline = re.search(r'Slot\s*(\d+)', line) - if slot_inline: - result['slot'] = int(slot_inline.group(1)) - return result - - # P25 NAC (Network Access Code) — check before voice/slot - nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line) - if nac_match: - return { - 'type': 'nac', - 'nac': nac_match.group(1), - 'timestamp': ts, - } - - # Voice frame detection — check BEFORE bare slot match - # Classic dsd: "Voice" keyword in frame lines - # dsd-fme: "voice" or "Voice LC" or "VOICE" in output - if re.search(r'\bvoice\b', line, re.IGNORECASE): - result = { - 'type': 'voice', - 'detail': line, - 'timestamp': ts, - } - slot_inline = re.search(r'Slot\s*(\d+)', line) - if slot_inline: - result['slot'] = int(slot_inline.group(1)) - return result - - # Bare slot info (only when line is *just* slot info, not voice/call) - slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line) - if slot_match: - return { - 'type': 'slot', - 'slot': int(slot_match.group(1)), - 'timestamp': ts, - } - - # dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc. - # Also catches "Closing", "Input", and other lifecycle lines. - # Forward as raw so the frontend can show decoder is alive. - return { - 'type': 'raw', - 'text': line[:200], - 'timestamp': ts, - } - - -_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle - -# 100ms of silence at 8kHz 16-bit mono = 1600 bytes -_SILENCE_CHUNK = b'\x00' * 1600 - - -def _register_audio_sink(sink: object) -> None: - """Register an ffmpeg stdin sink for mux fanout.""" - with _ffmpeg_sinks_lock: - _ffmpeg_sinks.add(sink) - - -def _unregister_audio_sink(sink: object) -> None: - """Remove an ffmpeg stdin sink from mux fanout.""" - with _ffmpeg_sinks_lock: - _ffmpeg_sinks.discard(sink) - - -def _get_audio_sinks() -> tuple[object, ...]: - """Snapshot current audio sinks for lock-free iteration.""" - with _ffmpeg_sinks_lock: - return tuple(_ffmpeg_sinks) - - -def _stop_process(proc: Optional[subprocess.Popen]) -> None: - """Terminate and unregister a subprocess if present.""" - if not proc: - return - if proc.poll() is None: - try: - proc.terminate() - proc.wait(timeout=2) - except Exception: - try: - proc.kill() - except Exception: - pass - unregister_process(proc) - - -def _reset_runtime_state(*, release_device: bool) -> None: - """Reset process + runtime state and optionally release SDR ownership.""" - global dmr_rtl_process, dmr_dsd_process - global dmr_running, dmr_has_audio, dmr_active_device - - _stop_process(dmr_dsd_process) - _stop_process(dmr_rtl_process) - dmr_rtl_process = None - dmr_dsd_process = None - dmr_running = False - dmr_has_audio = False - with _ffmpeg_sinks_lock: - _ffmpeg_sinks.clear() - - if release_device and dmr_active_device is not None: - app_module.release_sdr_device(dmr_active_device) - dmr_active_device = None - - -def _dsd_audio_mux(dsd_stdout): - """Mux thread: sole reader of dsd-fme stdout. - - Always drains dsd-fme's audio output to prevent the process from - blocking on stdout writes (which would also freeze stderr / text - data). When streaming clients are connected, forwards data to all - active ffmpeg stdin sinks with silence fill during voice gaps. - """ - try: - while dmr_running: - ready, _, _ = select.select([dsd_stdout], [], [], 0.1) - if ready: - data = os.read(dsd_stdout.fileno(), 4096) - if not data: - break - sinks = _get_audio_sinks() - for sink in sinks: - try: - sink.write(data) - sink.flush() - except (BrokenPipeError, OSError, ValueError): - _unregister_audio_sink(sink) - else: - # No audio from decoder — feed silence if client connected - sinks = _get_audio_sinks() - for sink in sinks: - try: - sink.write(_SILENCE_CHUNK) - sink.flush() - except (BrokenPipeError, OSError, ValueError): - _unregister_audio_sink(sink) - except (OSError, ValueError): - pass - - -def _queue_put(event: dict): - """Put an event on the DMR queue, dropping oldest if full.""" - try: - dmr_queue.put_nowait(event) - except queue.Full: - try: - dmr_queue.get_nowait() - except queue.Empty: - pass - try: - dmr_queue.put_nowait(event) - except queue.Full: - pass - - -def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen): - """Read DSD stderr output and push parsed events to the queue. - - Uses select() with a timeout so we can send periodic heartbeat - events while readline() would otherwise block indefinitely during - silence (no signal being decoded). - """ - global dmr_running - - try: - _queue_put({'type': 'status', 'text': 'started'}) - last_heartbeat = time.time() - - while dmr_running: - if dsd_process.poll() is not None: - break - - # Wait up to 1s for data on stderr instead of blocking forever - ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0) - - if ready: - line = dsd_process.stderr.readline() - if not line: - if dsd_process.poll() is not None: - break - continue - - text = line.decode('utf-8', errors='replace').strip() - if not text: - continue - - logger.debug("DSD raw: %s", text) - parsed = parse_dsd_output(text) - if parsed: - _queue_put(parsed) - last_heartbeat = time.time() - else: - # No stderr output — send heartbeat so frontend knows - # decoder is still alive and listening - now = time.time() - if now - last_heartbeat >= _HEARTBEAT_INTERVAL: - _queue_put({ - 'type': 'heartbeat', - 'timestamp': datetime.now().strftime('%H:%M:%S'), - }) - last_heartbeat = now - - except Exception as e: - logger.error(f"DSD stream error: {e}") - finally: - global dmr_active_device, dmr_rtl_process, dmr_dsd_process - global dmr_has_audio - dmr_running = False - dmr_has_audio = False - with _ffmpeg_sinks_lock: - _ffmpeg_sinks.clear() - # Capture exit info for diagnostics - rc = dsd_process.poll() - reason = 'stopped' - detail = '' - if rc is not None and rc != 0: - reason = 'crashed' - try: - remaining = dsd_process.stderr.read(1024) - if remaining: - detail = remaining.decode('utf-8', errors='replace').strip()[:200] - except Exception: - pass - logger.warning(f"DSD process exited with code {rc}: {detail}") - # Cleanup decoder + demod processes - _stop_process(dsd_process) - _stop_process(rtl_process) - dmr_rtl_process = None - dmr_dsd_process = None - _queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail}) - # Release SDR device - if dmr_active_device is not None: - app_module.release_sdr_device(dmr_active_device) - dmr_active_device = None - logger.info("DSD stream thread stopped") - - -# ============================================ -# API ENDPOINTS -# ============================================ - -@dmr_bp.route('/tools') -def check_tools() -> Response: - """Check for required tools.""" - dsd_path, _ = find_dsd() - rtl_fm = find_rtl_fm() - rx_fm = find_rx_fm() - ffmpeg = find_ffmpeg() - return jsonify({ - 'dsd': dsd_path is not None, - 'rtl_fm': rtl_fm is not None, - 'rx_fm': rx_fm is not None, - 'ffmpeg': ffmpeg is not None, - 'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None), - 'protocols': VALID_PROTOCOLS, - }) - - -@dmr_bp.route('/start', methods=['POST']) -def start_dmr() -> Response: - """Start digital voice decoding.""" - global dmr_rtl_process, dmr_dsd_process, dmr_thread - global dmr_running, dmr_has_audio, dmr_active_device - - dsd_path, is_fme = find_dsd() - if not dsd_path: - return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503 - - data = request.json or {} - - try: - frequency = validate_frequency(data.get('frequency', 462.5625)) - gain = int(validate_gain(data.get('gain', 40))) - device = validate_device_index(data.get('device', 0)) - protocol = str(data.get('protocol', 'auto')).lower() - ppm = validate_ppm(data.get('ppm', 0)) - except (ValueError, TypeError) as e: - return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 - - sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR - - if protocol not in VALID_PROTOCOLS: - return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400 - - if sdr_type == SDRType.RTL_SDR: - if not find_rtl_fm(): - return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503 - else: - if not find_rx_fm(): - return jsonify({ - 'status': 'error', - 'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.' - }), 503 - - # Clear stale queue - try: - while True: - dmr_queue.get_nowait() - except queue.Empty: - pass - - # Reserve running state before we start claiming resources/processes - # so concurrent /start requests cannot race each other. - with dmr_lock: - if dmr_running: - return jsonify({'status': 'error', 'message': 'Already running'}), 409 - dmr_running = True - dmr_has_audio = False - - # Claim SDR device — use protocol name so the device panel shows - # "D-STAR", "P25", etc. instead of always "DMR" - mode_label = protocol.upper() if protocol != 'auto' else 'DMR' - error = app_module.claim_sdr_device(device, mode_label) - if error: - with dmr_lock: - dmr_running = False - return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 - - dmr_active_device = device - - # Build FM demodulation command via SDR abstraction. - try: - sdr_device = SDRFactory.create_default_device(sdr_type, index=device) - builder = SDRFactory.get_builder(sdr_type) - rtl_cmd = builder.build_fm_demod_command( - device=sdr_device, - frequency_mhz=frequency, - sample_rate=48000, - gain=float(gain) if gain > 0 else None, - ppm=int(ppm) if ppm != 0 else None, - modulation='fm', - squelch=None, - bias_t=bool(data.get('bias_t', False)), - ) - if sdr_type == SDRType.RTL_SDR: - # Keep squelch fully open for digital bitstreams. - rtl_cmd.extend(['-l', '0']) - except Exception as e: - _reset_runtime_state(release_device=True) - return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 - - # Build DSD command - # Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for - # ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'. - # If ffmpeg is unavailable, fall back to discarding audio. - ffmpeg_path = find_ffmpeg() - if ffmpeg_path: - audio_out = '-' - else: - audio_out = 'null' if is_fme else '-' - logger.warning("ffmpeg not found — audio streaming disabled, data-only mode") - dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out] - if is_fme: - dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, [])) - dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, [])) - # Event log to stderr so we capture TG/Source/Voice data that - # dsd-fme may not output on stderr by default. - dsd_cmd.extend(['-J', '/dev/stderr']) - # Relax CRC checks for marginal signals — lets more frames - # through at the cost of occasional decode errors. - if data.get('relaxCrc', False): - dsd_cmd.append('-F') - else: - dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, [])) - - try: - dmr_rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - register_process(dmr_rtl_process) - - # DSD stdout → PIPE when ffmpeg available (audio pipeline), - # otherwise DEVNULL (data-only mode) - dsd_stdout = subprocess.PIPE if ffmpeg_path else subprocess.DEVNULL - dmr_dsd_process = subprocess.Popen( - dsd_cmd, - stdin=dmr_rtl_process.stdout, - stdout=dsd_stdout, - stderr=subprocess.PIPE, - ) - register_process(dmr_dsd_process) - - # Allow rtl_fm to send directly to dsd - dmr_rtl_process.stdout.close() - - # Start mux thread: always drains dsd-fme stdout to prevent the - # process from blocking (which would freeze stderr / text data). - # ffmpeg is started lazily per-client in /dmr/audio/stream. - if ffmpeg_path and dmr_dsd_process.stdout: - dmr_has_audio = True - threading.Thread( - target=_dsd_audio_mux, - args=(dmr_dsd_process.stdout,), - daemon=True, - ).start() - - time.sleep(0.3) - - rtl_rc = dmr_rtl_process.poll() - dsd_rc = dmr_dsd_process.poll() - if rtl_rc is not None or dsd_rc is not None: - # Process died — capture stderr for diagnostics - rtl_err = '' - if dmr_rtl_process.stderr: - rtl_err = dmr_rtl_process.stderr.read().decode('utf-8', errors='replace')[:500] - dsd_err = '' - if dmr_dsd_process.stderr: - dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500] - logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}") - # Terminate surviving processes and release resources. - _reset_runtime_state(release_device=True) - # Surface a clear error to the user - detail = rtl_err.strip() or dsd_err.strip() - if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err: - msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.' - elif detail: - msg = f'Failed to start DSD pipeline: {detail}' - else: - msg = 'Failed to start DSD pipeline' - return jsonify({'status': 'error', 'message': msg}), 500 - - # Drain rtl_fm stderr in background to prevent pipe blocking - def _drain_rtl_stderr(proc): - try: - for line in proc.stderr: - pass - except Exception: - pass - - threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start() - - dmr_thread = threading.Thread( - target=stream_dsd_output, - args=(dmr_rtl_process, dmr_dsd_process), - daemon=True, - ) - dmr_thread.start() - - return jsonify({ - 'status': 'started', - 'frequency': frequency, - 'protocol': protocol, - 'sdr_type': sdr_type.value, - 'has_audio': dmr_has_audio, - }) - - except Exception as e: - logger.error(f"Failed to start DMR: {e}") - _reset_runtime_state(release_device=True) - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@dmr_bp.route('/stop', methods=['POST']) -def stop_dmr() -> Response: - """Stop digital voice decoding.""" - with dmr_lock: - _reset_runtime_state(release_device=True) - - return jsonify({'status': 'stopped'}) - - -@dmr_bp.route('/status') -def dmr_status() -> Response: - """Get DMR decoder status.""" - return jsonify({ - 'running': dmr_running, - 'device': dmr_active_device, - 'has_audio': dmr_has_audio, - }) - - -@dmr_bp.route('/audio/stream') -def stream_dmr_audio() -> Response: - """Stream decoded digital voice audio as WAV. - - Starts a per-client ffmpeg encoder. The global mux thread - (_dsd_audio_mux) forwards DSD audio to this ffmpeg's stdin while - the client is connected, and discards audio otherwise. This avoids - the pipe-buffer deadlock that occurs when ffmpeg is started at - decoder launch (its stdout fills up before any HTTP client reads - it, back-pressuring the entire pipeline and freezing stderr/text - data output). - """ - if not dmr_running or not dmr_has_audio: - return Response(b'', mimetype='audio/wav', status=204) - - ffmpeg_path = find_ffmpeg() - if not ffmpeg_path: - return Response(b'', mimetype='audio/wav', status=503) - - encoder_cmd = [ - ffmpeg_path, '-hide_banner', '-loglevel', 'error', - '-fflags', 'nobuffer', '-flags', 'low_delay', - '-probesize', '32', '-analyzeduration', '0', - '-f', 's16le', '-ar', '8000', '-ac', '1', '-i', 'pipe:0', - '-acodec', 'pcm_s16le', '-ar', '44100', '-f', 'wav', 'pipe:1', - ] - audio_proc = subprocess.Popen( - encoder_cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - # Drain ffmpeg stderr to prevent blocking - threading.Thread( - target=lambda p: [None for _ in p.stderr], - args=(audio_proc,), daemon=True, - ).start() - - if audio_proc.stdin: - _register_audio_sink(audio_proc.stdin) - - def generate(): - try: - while dmr_running and audio_proc.poll() is None: - ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0) - if ready: - chunk = audio_proc.stdout.read(4096) - if chunk: - yield chunk - else: - break - else: - if audio_proc.poll() is not None: - break - except GeneratorExit: - pass - except Exception as e: - logger.error(f"DMR audio stream error: {e}") - finally: - # Disconnect mux → ffmpeg, then clean up - if audio_proc.stdin: - _unregister_audio_sink(audio_proc.stdin) - try: - audio_proc.stdin.close() - except Exception: - pass - try: - audio_proc.terminate() - audio_proc.wait(timeout=2) - except Exception: - try: - audio_proc.kill() - except Exception: - pass - - return Response( - generate(), - mimetype='audio/wav', - headers={ - 'Content-Type': 'audio/wav', - 'Cache-Control': 'no-cache, no-store', - 'X-Accel-Buffering': 'no', - 'Transfer-Encoding': 'chunked', - }, - ) - - -@dmr_bp.route('/stream') -def stream_dmr() -> Response: - """SSE stream for DMR decoder events.""" - def _on_msg(msg: dict[str, Any]) -> None: - process_event('dmr', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=dmr_queue, - channel_key='dmr', - timeout=SSE_QUEUE_TIMEOUT, - keepalive_interval=SSE_KEEPALIVE_INTERVAL, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response diff --git a/routes/drone_ops.py b/routes/drone_ops.py new file mode 100644 index 0000000..23de0d3 --- /dev/null +++ b/routes/drone_ops.py @@ -0,0 +1,431 @@ +"""Drone Ops routes: professional workflow for detection, incidents, actions, and evidence.""" + +from __future__ import annotations + +from flask import Blueprint, Response, jsonify, request + +from utils.authz import current_username, require_armed, require_role +from utils.database import ( + get_action_request, + get_drone_incident, + get_evidence_manifest, + list_action_audit_logs, + list_action_requests, + list_drone_correlations, + list_drone_sessions, + list_drone_incidents, + list_evidence_manifests, +) +from utils.drone import get_drone_ops_service +from utils.sse import format_sse + + +drone_ops_bp = Blueprint('drone_ops', __name__, url_prefix='/drone-ops') + + +def _json_body() -> dict: + return request.get_json(silent=True) or {} + + +@drone_ops_bp.route('/status', methods=['GET']) +@require_role('viewer') +def status() -> Response: + service = get_drone_ops_service() + return jsonify(service.get_status()) + + +@drone_ops_bp.route('/sessions', methods=['GET']) +@require_role('viewer') +def list_sessions() -> Response: + limit = max(1, min(500, request.args.get('limit', default=50, type=int))) + active_only = request.args.get('active_only', 'false').lower() == 'true' + return jsonify({ + 'status': 'success', + 'sessions': list_drone_sessions(limit=limit, active_only=active_only), + }) + + +@drone_ops_bp.route('/session/start', methods=['POST']) +@require_role('operator') +def start_session() -> Response: + data = _json_body() + mode = str(data.get('mode') or 'passive').strip().lower() + if mode not in {'passive', 'active'}: + return jsonify({'status': 'error', 'message': 'mode must be passive or active'}), 400 + + label = data.get('label') + metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} + + service = get_drone_ops_service() + session = service.start_session( + mode=mode, + label=label, + operator=current_username(), + metadata=metadata, + ) + return jsonify({'status': 'success', 'session': session}) + + +@drone_ops_bp.route('/session/stop', methods=['POST']) +@require_role('operator') +def stop_session() -> Response: + data = _json_body() + session_id = data.get('id') + try: + session_id_int = int(session_id) if session_id is not None else None + except (TypeError, ValueError): + return jsonify({'status': 'error', 'message': 'id must be an integer'}), 400 + + summary = data.get('summary') if isinstance(data.get('summary'), dict) else None + service = get_drone_ops_service() + session = service.stop_session( + operator=current_username(), + session_id=session_id_int, + summary=summary, + ) + if not session: + return jsonify({'status': 'error', 'message': 'No active session found'}), 404 + return jsonify({'status': 'success', 'session': session}) + + +@drone_ops_bp.route('/detections', methods=['GET']) +@require_role('viewer') +def detections() -> Response: + service = get_drone_ops_service() + source = request.args.get('source') + min_confidence = request.args.get('min_confidence', default=0.0, type=float) + limit = max(1, min(5000, request.args.get('limit', default=200, type=int))) + session_id = request.args.get('session_id', default=None, type=int) + + rows = service.get_detections( + session_id=session_id, + source=source, + min_confidence=min_confidence, + limit=limit, + ) + return jsonify({'status': 'success', 'count': len(rows), 'detections': rows}) + + +@drone_ops_bp.route('/stream', methods=['GET']) +@require_role('viewer') +def stream() -> Response: + service = get_drone_ops_service() + + def _generate(): + for event in service.stream_events(timeout=1.0): + evt_name = event.get('type') if isinstance(event, dict) else None + payload = event + yield format_sse(payload, event=evt_name) + + response = Response(_generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['Connection'] = 'keep-alive' + response.headers['X-Accel-Buffering'] = 'no' + return response + + +@drone_ops_bp.route('/remote-id/decode', methods=['POST']) +@require_role('analyst') +def decode_remote_id() -> Response: + data = _json_body() + payload = data.get('payload') + if payload is None: + return jsonify({'status': 'error', 'message': 'payload is required'}), 400 + + service = get_drone_ops_service() + decoded = service.decode_remote_id(payload) + return jsonify({'status': 'success', 'decoded': decoded}) + + +@drone_ops_bp.route('/tracks', methods=['GET']) +@require_role('viewer') +def tracks() -> Response: + service = get_drone_ops_service() + detection_id = request.args.get('detection_id', default=None, type=int) + identifier = request.args.get('identifier') + limit = max(1, min(5000, request.args.get('limit', default=1000, type=int))) + + rows = service.get_tracks(detection_id=detection_id, identifier=identifier, limit=limit) + return jsonify({'status': 'success', 'count': len(rows), 'tracks': rows}) + + +@drone_ops_bp.route('/geolocate/estimate', methods=['POST']) +@require_role('analyst') +def geolocate_estimate() -> Response: + data = _json_body() + observations = data.get('observations') + environment = str(data.get('environment') or 'outdoor') + + if not isinstance(observations, list) or len(observations) < 3: + return jsonify({'status': 'error', 'message': 'at least 3 observations are required'}), 400 + + service = get_drone_ops_service() + try: + location = service.estimate_geolocation(observations=observations, environment=environment) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + return jsonify({'status': 'success', 'location': location}) + + +@drone_ops_bp.route('/correlations', methods=['GET']) +@require_role('analyst') +def correlations() -> Response: + min_confidence = request.args.get('min_confidence', default=0.6, type=float) + refresh = request.args.get('refresh', 'true').lower() == 'true' + service = get_drone_ops_service() + + if refresh: + rows = service.refresh_correlations(min_confidence=min_confidence) + else: + rows = list_drone_correlations(min_confidence=min_confidence, limit=200) + + return jsonify({'status': 'success', 'count': len(rows), 'correlations': rows}) + + +@drone_ops_bp.route('/incidents', methods=['GET']) +@require_role('viewer') +def incidents_list() -> Response: + status = request.args.get('status') + limit = max(1, min(1000, request.args.get('limit', default=100, type=int))) + rows = list_drone_incidents(status=status, limit=limit) + return jsonify({'status': 'success', 'count': len(rows), 'incidents': rows}) + + +@drone_ops_bp.route('/incidents', methods=['POST']) +@require_role('operator') +def incidents_create() -> Response: + data = _json_body() + title = str(data.get('title') or '').strip() + if not title: + return jsonify({'status': 'error', 'message': 'title is required'}), 400 + + severity = str(data.get('severity') or 'medium').strip().lower() + summary = data.get('summary') + metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} + + service = get_drone_ops_service() + incident = service.create_incident( + title=title, + severity=severity, + opened_by=current_username(), + summary=summary, + metadata=metadata, + ) + return jsonify({'status': 'success', 'incident': incident}), 201 + + +@drone_ops_bp.route('/incidents/', methods=['GET']) +@require_role('viewer') +def incidents_get(incident_id: int) -> Response: + incident = get_drone_incident(incident_id) + if not incident: + return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 + return jsonify({'status': 'success', 'incident': incident}) + + +@drone_ops_bp.route('/incidents/', methods=['PUT']) +@require_role('operator') +def incidents_update(incident_id: int) -> Response: + data = _json_body() + service = get_drone_ops_service() + incident = service.update_incident( + incident_id=incident_id, + status=data.get('status'), + severity=data.get('severity'), + summary=data.get('summary'), + metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else None, + ) + if not incident: + return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 + return jsonify({'status': 'success', 'incident': incident}) + + +@drone_ops_bp.route('/incidents//artifacts', methods=['POST']) +@require_role('operator') +def incidents_add_artifact(incident_id: int) -> Response: + if not get_drone_incident(incident_id): + return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 + + data = _json_body() + artifact_type = str(data.get('artifact_type') or '').strip() + artifact_ref = str(data.get('artifact_ref') or '').strip() + metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} + + if not artifact_type or not artifact_ref: + return jsonify({'status': 'error', 'message': 'artifact_type and artifact_ref are required'}), 400 + + service = get_drone_ops_service() + artifact = service.add_incident_artifact( + incident_id=incident_id, + artifact_type=artifact_type, + artifact_ref=artifact_ref, + added_by=current_username(), + metadata=metadata, + ) + return jsonify({'status': 'success', 'artifact': artifact}), 201 + + +@drone_ops_bp.route('/actions/arm', methods=['POST']) +@require_role('operator') +def actions_arm() -> Response: + data = _json_body() + reason = str(data.get('reason') or '').strip() + incident_id = data.get('incident_id') + duration_seconds = data.get('duration_seconds', 900) + + if not reason: + return jsonify({'status': 'error', 'message': 'reason is required'}), 400 + try: + incident_id_int = int(incident_id) + except (TypeError, ValueError): + return jsonify({'status': 'error', 'message': 'incident_id is required and must be an integer'}), 400 + + if not get_drone_incident(incident_id_int): + return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 + + service = get_drone_ops_service() + state = service.arm_actions( + actor=current_username(), + reason=reason, + incident_id=incident_id_int, + duration_seconds=duration_seconds, + ) + return jsonify({'status': 'success', 'policy': state}) + + +@drone_ops_bp.route('/actions/disarm', methods=['POST']) +@require_role('operator') +def actions_disarm() -> Response: + data = _json_body() + reason = str(data.get('reason') or '').strip() or None + service = get_drone_ops_service() + state = service.disarm_actions(actor=current_username(), reason=reason) + return jsonify({'status': 'success', 'policy': state}) + + +@drone_ops_bp.route('/actions/request', methods=['POST']) +@require_role('operator') +def actions_request() -> Response: + data = _json_body() + try: + incident_id = int(data.get('incident_id')) + except (TypeError, ValueError): + return jsonify({'status': 'error', 'message': 'incident_id is required'}), 400 + + if not get_drone_incident(incident_id): + return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 + + action_type = str(data.get('action_type') or '').strip() + if not action_type: + return jsonify({'status': 'error', 'message': 'action_type is required'}), 400 + + payload = data.get('payload') if isinstance(data.get('payload'), dict) else {} + + service = get_drone_ops_service() + action_request = service.request_action( + incident_id=incident_id, + action_type=action_type, + requested_by=current_username(), + payload=payload, + ) + return jsonify({'status': 'success', 'request': action_request}), 201 + + +@drone_ops_bp.route('/actions/approve/', methods=['POST']) +@require_role('supervisor') +def actions_approve(request_id: int) -> Response: + data = _json_body() + decision = str(data.get('decision') or 'approved').strip().lower() + notes = data.get('notes') + + if decision not in {'approved', 'rejected'}: + return jsonify({'status': 'error', 'message': 'decision must be approved or rejected'}), 400 + + service = get_drone_ops_service() + req = service.approve_action( + request_id=request_id, + approver=current_username(), + decision=decision, + notes=notes, + ) + if not req: + return jsonify({'status': 'error', 'message': 'Action request not found'}), 404 + return jsonify({'status': 'success', 'request': req}) + + +@drone_ops_bp.route('/actions/execute/', methods=['POST']) +@require_role('operator') +@require_armed +def actions_execute(request_id: int) -> Response: + service = get_drone_ops_service() + req, error = service.execute_action(request_id=request_id, actor=current_username()) + if error: + return jsonify({'status': 'error', 'message': error}), 400 + return jsonify({'status': 'success', 'request': req}) + + +@drone_ops_bp.route('/actions/requests', methods=['GET']) +@require_role('viewer') +def actions_list() -> Response: + incident_id = request.args.get('incident_id', default=None, type=int) + status = request.args.get('status') + limit = max(1, min(1000, request.args.get('limit', default=100, type=int))) + + rows = list_action_requests(incident_id=incident_id, status=status, limit=limit) + return jsonify({'status': 'success', 'count': len(rows), 'requests': rows}) + + +@drone_ops_bp.route('/actions/requests/', methods=['GET']) +@require_role('viewer') +def actions_get(request_id: int) -> Response: + row = get_action_request(request_id) + if not row: + return jsonify({'status': 'error', 'message': 'Action request not found'}), 404 + return jsonify({'status': 'success', 'request': row}) + + +@drone_ops_bp.route('/actions/audit', methods=['GET']) +@require_role('viewer') +def actions_audit() -> Response: + request_id = request.args.get('request_id', default=None, type=int) + limit = max(1, min(2000, request.args.get('limit', default=200, type=int))) + rows = list_action_audit_logs(request_id=request_id, limit=limit) + return jsonify({'status': 'success', 'count': len(rows), 'events': rows}) + + +@drone_ops_bp.route('/evidence//manifest', methods=['POST']) +@require_role('analyst') +def evidence_manifest_create(incident_id: int) -> Response: + if not get_drone_incident(incident_id): + return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 + + data = _json_body() + signature = data.get('signature') + + service = get_drone_ops_service() + manifest = service.generate_evidence_manifest( + incident_id=incident_id, + created_by=current_username(), + signature=signature, + ) + if not manifest: + return jsonify({'status': 'error', 'message': 'Failed to generate manifest'}), 500 + return jsonify({'status': 'success', 'manifest': manifest}), 201 + + +@drone_ops_bp.route('/evidence/manifests/', methods=['GET']) +@require_role('viewer') +def evidence_manifest_get(manifest_id: int) -> Response: + row = get_evidence_manifest(manifest_id) + if not row: + return jsonify({'status': 'error', 'message': 'Manifest not found'}), 404 + return jsonify({'status': 'success', 'manifest': row}) + + +@drone_ops_bp.route('/evidence//manifests', methods=['GET']) +@require_role('viewer') +def evidence_manifest_list(incident_id: int) -> Response: + limit = max(1, min(500, request.args.get('limit', default=50, type=int))) + rows = list_evidence_manifests(incident_id=incident_id, limit=limit) + return jsonify({'status': 'success', 'count': len(rows), 'manifests': rows}) diff --git a/routes/subghz.py b/routes/subghz.py index 823e09d..83fe45c 100644 --- a/routes/subghz.py +++ b/routes/subghz.py @@ -10,9 +10,10 @@ import queue from flask import Blueprint, jsonify, request, Response, send_file -from utils.logging import get_logger -from utils.sse import sse_stream -from utils.subghz import get_subghz_manager +from utils.logging import get_logger +from utils.sse import sse_stream +from utils.subghz import get_subghz_manager +from utils.event_pipeline import process_event from utils.constants import ( SUBGHZ_FREQ_MIN_MHZ, SUBGHZ_FREQ_MAX_MHZ, @@ -32,10 +33,14 @@ subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz') _subghz_queue: queue.Queue = queue.Queue(maxsize=200) -def _event_callback(event: dict) -> None: - """Forward SubGhzManager events to the SSE queue.""" - try: - _subghz_queue.put_nowait(event) +def _event_callback(event: dict) -> None: + """Forward SubGhzManager events to the SSE queue.""" + try: + process_event('subghz', event, event.get('type')) + except Exception: + pass + try: + _subghz_queue.put_nowait(event) except queue.Full: try: _subghz_queue.get_nowait() diff --git a/setup.sh b/setup.sh index 60d0fa3..148fd6f 100755 --- a/setup.sh +++ b/setup.sh @@ -233,10 +233,6 @@ check_tools() { info "GPS:" check_required "gpsd" "GPS daemon" gpsd - echo - info "Digital Voice:" - check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme - echo info "Audio:" check_required "ffmpeg" "Audio encoder/decoder" ffmpeg @@ -458,95 +454,6 @@ install_multimon_ng_from_source_macos() { ) } -install_dsd_from_source() { - info "Building DSD (Digital Speech Decoder) from source..." - info "This requires mbelib (vocoder library) as a prerequisite." - - if [[ "$OS" == "macos" ]]; then - brew_install cmake - brew_install libsndfile - brew_install ncurses - brew_install fftw - brew_install codec2 - brew_install librtlsdr - brew_install pulseaudio || true - else - apt_install build-essential git cmake libsndfile1-dev libpulse-dev \ - libfftw3-dev liblapack-dev libncurses-dev librtlsdr-dev libcodec2-dev - fi - - ( - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' EXIT - - # Step 1: Build and install mbelib (required dependency) - info "Building mbelib (vocoder library)..." - git clone https://github.com/lwvmobile/mbelib.git "$tmp_dir/mbelib" >/dev/null 2>&1 \ - || { warn "Failed to clone mbelib"; exit 1; } - - cd "$tmp_dir/mbelib" - git checkout ambe_tones >/dev/null 2>&1 || true - mkdir -p build && cd build - - if cmake .. >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then - if [[ "$OS" == "macos" ]]; then - if [[ -w /usr/local/lib ]]; then - make install >/dev/null 2>&1 - else - refresh_sudo - $SUDO make install >/dev/null 2>&1 - fi - else - $SUDO make install >/dev/null 2>&1 - $SUDO ldconfig 2>/dev/null || true - fi - ok "mbelib installed" - else - warn "Failed to build mbelib. Cannot build DSD without it." - exit 1 - fi - - # Step 2: Build dsd-fme (or fall back to original dsd) - info "Building dsd-fme..." - git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \ - || { warn "Failed to clone dsd-fme, trying original DSD..."; - git clone --depth 1 https://github.com/szechyjs/dsd.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \ - || { warn "Failed to clone DSD"; exit 1; }; } - - cd "$tmp_dir/dsd-fme" - mkdir -p build && cd build - - # On macOS, help cmake find Homebrew ncurses - local cmake_flags="" - if [[ "$OS" == "macos" ]]; then - local ncurses_prefix - ncurses_prefix="$(brew --prefix ncurses 2>/dev/null || echo /opt/homebrew/opt/ncurses)" - cmake_flags="-DCMAKE_PREFIX_PATH=$ncurses_prefix" - fi - - info "Compiling DSD..." - if cmake .. $cmake_flags >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then - if [[ "$OS" == "macos" ]]; then - if [[ -w /usr/local/bin ]]; then - install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true - else - refresh_sudo - $SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true - fi - else - $SUDO make install >/dev/null 2>&1 \ - || $SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null \ - || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null \ - || true - $SUDO ldconfig 2>/dev/null || true - fi - ok "DSD installed successfully" - else - warn "Failed to build DSD from source. DMR/P25 decoding will not be available." - fi - ) -} - install_dump1090_from_source_macos() { info "dump1090 not available via Homebrew. Building from source..." @@ -918,7 +825,7 @@ install_macos_packages() { sudo -v || { fail "sudo authentication failed"; exit 1; } fi - TOTAL_STEPS=22 + TOTAL_STEPS=21 CURRENT_STEP=0 progress "Checking Homebrew" @@ -941,19 +848,6 @@ install_macos_packages() { progress "SSTV decoder" ok "SSTV uses built-in pure Python decoder (no external tools needed)" - progress "Installing DSD (Digital Speech Decoder, optional)" - if ! cmd_exists dsd && ! cmd_exists dsd-fme; then - echo - info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding." - if ask_yes_no "Do you want to install DSD?"; then - install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available." - else - warn "Skipping DSD installation. DMR/P25 decoding will not be available." - fi - else - ok "DSD already installed" - fi - progress "Installing ffmpeg" brew_install ffmpeg @@ -1409,7 +1303,7 @@ install_debian_packages() { export NEEDRESTART_MODE=a fi - TOTAL_STEPS=28 + TOTAL_STEPS=27 CURRENT_STEP=0 progress "Updating APT package lists" @@ -1474,19 +1368,6 @@ install_debian_packages() { progress "SSTV decoder" ok "SSTV uses built-in pure Python decoder (no external tools needed)" - progress "Installing DSD (Digital Speech Decoder, optional)" - if ! cmd_exists dsd && ! cmd_exists dsd-fme; then - echo - info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding." - if ask_yes_no "Do you want to install DSD?"; then - install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available." - else - warn "Skipping DSD installation. DMR/P25 decoding will not be available." - fi - else - ok "DSD already installed" - fi - progress "Installing ffmpeg" apt_install ffmpeg diff --git a/static/css/index.css b/static/css/index.css index 526648c..99691c7 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -2172,6 +2172,10 @@ header h1 .tagline { } .control-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; padding: 6px 12px; background: transparent; border: 1px solid var(--border-color); @@ -2182,6 +2186,14 @@ header h1 .tagline { letter-spacing: 1px; transition: all 0.2s ease; font-family: var(--font-sans); + line-height: 1.1; + white-space: nowrap; +} + +.control-btn .icon { + display: inline-flex; + align-items: center; + justify-content: center; } .control-btn:hover { diff --git a/static/css/modes/droneops.css b/static/css/modes/droneops.css new file mode 100644 index 0000000..573e360 --- /dev/null +++ b/static/css/modes/droneops.css @@ -0,0 +1,498 @@ +/* Drone Ops mode styling */ + +#droneOpsMode { + display: none; +} + +#droneOpsMode.active { + display: block; +} + +.droneops-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 10px; +} + +.droneops-status-card { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 6px; + padding: 8px; +} + +.droneops-status-label { + font-size: 10px; + color: var(--text-secondary); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.droneops-status-value { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); +} + +.droneops-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 8px; + align-items: stretch; +} + +.droneops-row > * { + min-width: 0; +} + +.droneops-row--actions { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +.droneops-source-block { + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); + padding: 8px; + margin-bottom: 8px; +} + +.droneops-source-title { + font-size: 10px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 6px; +} + +.droneops-row--sources { + margin-bottom: 0; +} + +.droneops-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.droneops-field-label { + font-size: 10px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.droneops-source-refresh { + grid-column: 1 / -1; +} + +.droneops-input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: 5px; + padding: 6px 8px; + font-size: 11px; +} + +#droneOpsMode .preset-btn, +#droneOpsMode .clear-btn { + width: 100%; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + line-height: 1.2; + white-space: normal !important; + overflow-wrap: anywhere; + word-break: break-word; + letter-spacing: 0.05em; + font-size: 11px; + padding: 8px 10px; +} + +.droneops-list { + max-height: 260px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); +} + +.droneops-item { + border-bottom: 1px solid var(--border-color); + padding: 8px; + font-size: 11px; +} + +.droneops-item:last-child { + border-bottom: none; +} + +.droneops-item-title { + font-weight: 700; + color: var(--text-primary); +} + +.droneops-item-meta { + margin-top: 3px; + color: var(--text-secondary); + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.droneops-item-actions { + margin-top: 6px; + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.droneops-check { + width: 100%; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + font-size: 11px; + color: var(--text-primary); + border: 1px solid var(--border-color); + background: var(--bg-tertiary); + border-radius: 5px; + padding: 6px 8px; +} + +body[data-mode="droneops"] #muteBtn, +body[data-mode="droneops"] #autoScrollBtn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +body[data-mode="droneops"] #muteBtn .icon, +body[data-mode="droneops"] #autoScrollBtn .icon { + line-height: 0; +} + +.droneops-check input { + margin: 0; +} + +.droneops-subtext { + margin: 4px 0 10px; + font-size: 11px; + color: var(--text-secondary); +} + +.droneops-pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 2px 8px; + font-size: 10px; + border: 1px solid var(--border-color); + color: var(--text-secondary); +} + +.droneops-pill.ok { + color: var(--accent-green); + border-color: var(--accent-green); +} + +.droneops-pill.warn { + color: var(--accent-orange); + border-color: var(--accent-orange); +} + +.droneops-pill.bad { + color: var(--accent-red); + border-color: var(--accent-red); +} + +.droneops-empty { + color: var(--text-secondary); + padding: 10px; + font-size: 11px; +} + +.droneops-main-pane { + display: none; + flex: 1; + min-height: 0; + padding: 10px; + gap: 10px; + flex-direction: column; +} + +.droneops-main-top { + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(300px, 1fr); + gap: 10px; + min-height: 0; + flex: 1; +} + +.droneops-main-bottom { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + min-height: 260px; + flex: 1; +} + +.droneops-main-side { + display: grid; + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + min-height: 0; +} + +.droneops-main-map-card, +.droneops-main-card { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.droneops-main-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); +} + +.droneops-main-card-header h4 { + margin: 0; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-primary); +} + +.droneops-main-card-meta { + font-size: 10px; + color: var(--text-secondary); + font-family: var(--font-mono); +} + +.droneops-main-kpis { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + padding: 10px; +} + +.droneops-main-kpi { + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.droneops-main-kpi span { + font-size: 10px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.droneops-main-kpi strong { + font-size: 15px; + color: var(--accent-cyan); + line-height: 1; + overflow-wrap: anywhere; +} + +.droneops-main-map { + flex: 1; + min-height: 320px; +} + +#droneOpsMap { + width: 100%; + height: 100%; + min-height: 320px; + background: var(--bg-primary); +} + +.droneops-main-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.droneops-main-list.compact { + gap: 6px; +} + +.droneops-main-item { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px; + background: var(--bg-primary); +} + +.droneops-main-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.droneops-main-item-id { + color: var(--text-primary); + font-weight: 700; + font-size: 12px; + overflow-wrap: anywhere; +} + +.droneops-main-item-meta { + margin-top: 4px; + color: var(--text-secondary); + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; +} + +.droneops-main-telemetry-row { + display: grid; + grid-template-columns: minmax(0, 1.2fr) repeat(5, minmax(0, 1fr)); + gap: 8px; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 8px; + background: var(--bg-primary); + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); +} + +.droneops-main-telemetry-row strong { + color: var(--text-primary); + font-weight: 700; +} + +.droneops-main-telemetry-row > span { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.droneops-main-correlation-row { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 7px 8px; + background: var(--bg-primary); + display: flex; + flex-direction: column; + gap: 3px; +} + +.droneops-main-correlation-row strong { + font-size: 11px; + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.droneops-main-correlation-row span { + font-size: 10px; + color: var(--text-secondary); +} + +.droneops-main-pane .leaflet-popup-content-wrapper { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); +} + +.droneops-main-pane .leaflet-popup-tip { + background: var(--bg-secondary); +} + +@media (max-width: 1400px) { + .droneops-main-top { + grid-template-columns: 1fr; + } + + .droneops-main-side { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: none; + } +} + +@media (max-width: 1100px) { + .droneops-main-bottom { + grid-template-columns: 1fr; + } + + .droneops-main-kpis { + grid-template-columns: 1fr; + } +} + +@media (max-width: 780px) { + .droneops-main-side { + grid-template-columns: 1fr; + } + + .droneops-main-telemetry-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .droneops-main-telemetry-row > span:first-child { + grid-column: 1 / -1; + } + + .droneops-main-map, + #droneOpsMap { + min-height: 240px; + } +} + +@media (max-width: 900px) { + .droneops-row { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + .droneops-row--actions { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } +} + +@media (max-width: 560px) { + .droneops-row { + grid-template-columns: 1fr; + } + + .droneops-row--actions { + grid-template-columns: 1fr; + } +} diff --git a/static/js/components/signal-guess.js b/static/js/components/signal-guess.js index 9b75e63..b583b39 100644 --- a/static/js/components/signal-guess.js +++ b/static/js/components/signal-guess.js @@ -289,23 +289,10 @@ const SignalGuess = (function() { regions: ['GLOBAL'] }, - // LoRaWAN - { - label: 'LoRaWAN / LoRa Device', - tags: ['iot', 'lora', 'lpwan', 'telemetry'], - description: 'LoRa long-range IoT device', - frequencyRanges: [[863000000, 870000000], [902000000, 928000000]], - modulationHints: ['LoRa', 'CSS', 'FSK'], - bandwidthRange: [125000, 500000], - baseScore: 11, - isBurstType: true, - regions: ['UK/EU', 'US'] - }, - - // Key Fob - { - label: 'Remote Control / Key Fob', - tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'], + // Key Fob + { + label: 'Remote Control / Key Fob', + tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'], description: 'Wireless remote control or vehicle key fob', frequencyRanges: [[314900000, 315100000], [433050000, 434790000], [867000000, 869000000]], modulationHints: ['OOK', 'ASK', 'FSK', 'rolling'], diff --git a/static/js/core/command-palette.js b/static/js/core/command-palette.js index 0938480..9502553 100644 --- a/static/js/core/command-palette.js +++ b/static/js/core/command-palette.js @@ -24,7 +24,6 @@ const CommandPalette = (function() { { mode: 'sstv_general', label: 'HF SSTV' }, { mode: 'gps', label: 'GPS' }, { mode: 'meshtastic', label: 'Meshtastic' }, - { mode: 'dmr', label: 'Digital Voice' }, { mode: 'websdr', label: 'WebSDR' }, { mode: 'analytics', label: 'Analytics' }, { mode: 'spaceweather', label: 'Space Weather' }, diff --git a/static/js/core/run-state.js b/static/js/core/run-state.js index 468b9a1..b9ea250 100644 --- a/static/js/core/run-state.js +++ b/static/js/core/run-state.js @@ -2,7 +2,7 @@ const RunState = (function() { 'use strict'; const REFRESH_MS = 5000; - const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'dmr', 'subghz']; + const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'subghz']; const MODE_ALIASES = { bt: 'bluetooth', bt_locate: 'bluetooth', @@ -21,7 +21,6 @@ const RunState = (function() { vdl2: 'VDL2', aprs: 'APRS', dsc: 'DSC', - dmr: 'DMR', subghz: 'SubGHz', }; @@ -181,7 +180,6 @@ const RunState = (function() { if (normalized.includes('aprs')) return 'aprs'; if (normalized.includes('dsc')) return 'dsc'; if (normalized.includes('subghz')) return 'subghz'; - if (normalized.includes('dmr')) return 'dmr'; if (normalized.includes('433')) return 'sensor'; return 'pager'; } diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js deleted file mode 100644 index bb5c811..0000000 --- a/static/js/modes/dmr.js +++ /dev/null @@ -1,852 +0,0 @@ -/** - * Intercept - DMR / Digital Voice Mode - * Decoding DMR, P25, NXDN, D-STAR digital voice protocols - */ - -// ============== STATE ============== -let isDmrRunning = false; -let dmrEventSource = null; -let dmrCallCount = 0; -let dmrSyncCount = 0; -let dmrCallHistory = []; -let dmrCurrentProtocol = '--'; -let dmrModeLabel = 'dmr'; // Protocol label for device reservation -let dmrHasAudio = false; - -// ============== BOOKMARKS ============== -let dmrBookmarks = []; -const DMR_BOOKMARKS_KEY = 'dmrBookmarks'; -const DMR_SETTINGS_KEY = 'dmrSettings'; -const DMR_BOOKMARK_PROTOCOLS = new Set(['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']); - -// ============== SYNTHESIZER STATE ============== -let dmrSynthCanvas = null; -let dmrSynthCtx = null; -let dmrSynthBars = []; -let dmrSynthAnimationId = null; -let dmrSynthInitialized = false; -let dmrActivityLevel = 0; -let dmrActivityTarget = 0; -let dmrEventType = 'idle'; -let dmrLastEventTime = 0; -const DMR_BAR_COUNT = 48; -const DMR_DECAY_RATE = 0.015; -const DMR_BURST_SYNC = 0.6; -const DMR_BURST_CALL = 0.85; -const DMR_BURST_VOICE = 0.95; - -// ============== TOOLS CHECK ============== - -function checkDmrTools() { - fetch('/dmr/tools') - .then(r => r.json()) - .then(data => { - const warning = document.getElementById('dmrToolsWarning'); - const warningText = document.getElementById('dmrToolsWarningText'); - if (!warning) return; - - const selectedType = (typeof getSelectedSDRType === 'function') - ? getSelectedSDRType() - : 'rtlsdr'; - const missing = []; - if (!data.dsd) missing.push('dsd (Digital Speech Decoder)'); - if (selectedType === 'rtlsdr') { - if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)'); - } else if (!data.rx_fm) { - missing.push('rx_fm (SoapySDR demodulator)'); - } - if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)'); - - if (missing.length > 0) { - warning.style.display = 'block'; - if (warningText) warningText.textContent = missing.join(', '); - } else { - warning.style.display = 'none'; - } - - // Update audio panel availability - updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE'); - }) - .catch(() => {}); -} - -// ============== START / STOP ============== - -function startDmr() { - const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625); - const protocol = document.getElementById('dmrProtocol')?.value || 'auto'; - const gain = parseInt(document.getElementById('dmrGain')?.value || 40); - const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0); - const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false; - const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; - const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr'; - - // Use protocol name for device reservation so panel shows "D-STAR", "P25", etc. - dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr'; - - // Check device availability before starting - if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) { - return; - } - - // Save settings to localStorage for persistence - try { - localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({ - frequency, protocol, gain, ppm, relaxCrc - })); - } catch (e) { /* localStorage unavailable */ } - - fetch('/dmr/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType }) - }) - .then(r => r.json()) - .then(data => { - if (data.status === 'started') { - isDmrRunning = true; - dmrCallCount = 0; - dmrSyncCount = 0; - dmrCallHistory = []; - updateDmrUI(); - connectDmrSSE(); - dmrEventType = 'idle'; - dmrActivityTarget = 0.1; - dmrLastEventTime = Date.now(); - if (!dmrSynthInitialized) initDmrSynthesizer(); - updateDmrSynthStatus(); - const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'DECODING'; - if (typeof reserveDevice === 'function') { - reserveDevice(parseInt(device), dmrModeLabel); - } - // Start audio if available - dmrHasAudio = !!data.has_audio; - if (dmrHasAudio) startDmrAudio(); - updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE'); - if (typeof showNotification === 'function') { - showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`); - } - } else if (data.status === 'error' && data.message === 'Already running') { - // Backend has an active session the frontend lost track of — resync - isDmrRunning = true; - updateDmrUI(); - connectDmrSSE(); - if (!dmrSynthInitialized) initDmrSynthesizer(); - dmrEventType = 'idle'; - dmrActivityTarget = 0.1; - dmrLastEventTime = Date.now(); - updateDmrSynthStatus(); - const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'DECODING'; - if (typeof showNotification === 'function') { - showNotification('DMR', 'Reconnected to active session'); - } - } else { - if (typeof showNotification === 'function') { - showNotification('Error', data.message || 'Failed to start DMR'); - } - } - }) - .catch(err => console.error('[DMR] Start error:', err)); -} - -function stopDmr() { - stopDmrAudio(); - fetch('/dmr/stop', { method: 'POST' }) - .then(r => r.json()) - .then(() => { - isDmrRunning = false; - if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; } - updateDmrUI(); - dmrEventType = 'stopped'; - dmrActivityTarget = 0; - updateDmrSynthStatus(); - updateDmrAudioStatus('OFF'); - const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'STOPPED'; - if (typeof releaseDevice === 'function') { - releaseDevice(dmrModeLabel); - } - }) - .catch(err => console.error('[DMR] Stop error:', err)); -} - -// ============== SSE STREAMING ============== - -function connectDmrSSE() { - if (dmrEventSource) dmrEventSource.close(); - dmrEventSource = new EventSource('/dmr/stream'); - - dmrEventSource.onmessage = function(event) { - const msg = JSON.parse(event.data); - handleDmrMessage(msg); - }; - - dmrEventSource.onerror = function() { - if (isDmrRunning) { - setTimeout(connectDmrSSE, 2000); - } - }; -} - -function handleDmrMessage(msg) { - if (dmrSynthInitialized) dmrSynthPulse(msg.type); - - if (msg.type === 'sync') { - dmrCurrentProtocol = msg.protocol || '--'; - const protocolEl = document.getElementById('dmrActiveProtocol'); - if (protocolEl) protocolEl.textContent = dmrCurrentProtocol; - const mainProtocolEl = document.getElementById('dmrMainProtocol'); - if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol; - dmrSyncCount++; - const syncCountEl = document.getElementById('dmrSyncCount'); - if (syncCountEl) syncCountEl.textContent = dmrSyncCount; - } else if (msg.type === 'call') { - dmrCallCount++; - const countEl = document.getElementById('dmrCallCount'); - if (countEl) countEl.textContent = dmrCallCount; - const mainCountEl = document.getElementById('dmrMainCallCount'); - if (mainCountEl) mainCountEl.textContent = dmrCallCount; - - // Update current call display - const slotInfo = msg.slot != null ? ` -
- Slot - ${msg.slot} -
` : ''; - const callEl = document.getElementById('dmrCurrentCall'); - if (callEl) { - callEl.innerHTML = ` -
- Talkgroup - ${msg.talkgroup} -
-
- Source ID - ${msg.source_id} -
${slotInfo} -
- Time - ${msg.timestamp} -
- `; - } - - // Add to history - dmrCallHistory.unshift({ - talkgroup: msg.talkgroup, - source_id: msg.source_id, - protocol: dmrCurrentProtocol, - time: msg.timestamp, - }); - if (dmrCallHistory.length > 50) dmrCallHistory.length = 50; - renderDmrHistory(); - - } else if (msg.type === 'slot') { - // Update slot info in current call - } else if (msg.type === 'raw') { - // Raw DSD output — triggers synthesizer activity via dmrSynthPulse - } else if (msg.type === 'heartbeat') { - // Decoder is alive and listening — keep synthesizer in listening state - if (isDmrRunning && dmrSynthInitialized) { - if (dmrEventType === 'idle' || dmrEventType === 'raw') { - dmrEventType = 'raw'; - dmrActivityTarget = Math.max(dmrActivityTarget, 0.15); - dmrLastEventTime = Date.now(); - updateDmrSynthStatus(); - } - } - } else if (msg.type === 'status') { - const statusEl = document.getElementById('dmrStatus'); - if (msg.text === 'started') { - if (statusEl) statusEl.textContent = 'DECODING'; - } else if (msg.text === 'crashed') { - isDmrRunning = false; - stopDmrAudio(); - updateDmrUI(); - dmrEventType = 'stopped'; - dmrActivityTarget = 0; - updateDmrSynthStatus(); - updateDmrAudioStatus('OFF'); - if (statusEl) statusEl.textContent = 'CRASHED'; - if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel); - const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`; - if (typeof showNotification === 'function') { - showNotification('DMR Error', detail); - } - } else if (msg.text === 'stopped') { - isDmrRunning = false; - stopDmrAudio(); - updateDmrUI(); - dmrEventType = 'stopped'; - dmrActivityTarget = 0; - updateDmrSynthStatus(); - updateDmrAudioStatus('OFF'); - if (statusEl) statusEl.textContent = 'STOPPED'; - if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel); - } - } -} - -// ============== UI ============== - -function updateDmrUI() { - const startBtn = document.getElementById('startDmrBtn'); - const stopBtn = document.getElementById('stopDmrBtn'); - if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block'; - if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none'; -} - -function renderDmrHistory() { - const container = document.getElementById('dmrHistoryBody'); - if (!container) return; - - const historyCountEl = document.getElementById('dmrHistoryCount'); - if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`; - - if (dmrCallHistory.length === 0) { - container.innerHTML = 'No calls recorded'; - return; - } - - container.innerHTML = dmrCallHistory.slice(0, 20).map(call => ` - - ${call.time} - ${call.talkgroup} - ${call.source_id} - ${call.protocol} - - `).join(''); -} - -// ============== SYNTHESIZER ============== - -function initDmrSynthesizer() { - dmrSynthCanvas = document.getElementById('dmrSynthCanvas'); - if (!dmrSynthCanvas) return; - - // Use the canvas element's own rendered size for the backing buffer - const rect = dmrSynthCanvas.getBoundingClientRect(); - const w = Math.round(rect.width) || 600; - const h = Math.round(rect.height) || 70; - dmrSynthCanvas.width = w; - dmrSynthCanvas.height = h; - - dmrSynthCtx = dmrSynthCanvas.getContext('2d'); - - dmrSynthBars = []; - for (let i = 0; i < DMR_BAR_COUNT; i++) { - dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 }; - } - - dmrActivityLevel = 0; - dmrActivityTarget = 0; - dmrEventType = isDmrRunning ? 'idle' : 'stopped'; - dmrSynthInitialized = true; - - updateDmrSynthStatus(); - - if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId); - drawDmrSynthesizer(); -} - -function drawDmrSynthesizer() { - if (!dmrSynthCtx || !dmrSynthCanvas) return; - - const width = dmrSynthCanvas.width; - const height = dmrSynthCanvas.height; - const barWidth = (width / DMR_BAR_COUNT) - 2; - const now = Date.now(); - - // Clear canvas - dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; - dmrSynthCtx.fillRect(0, 0, width, height); - - // Decay activity toward target. Window must exceed the backend - // heartbeat interval (3s) so the status doesn't flip-flop between - // LISTENING and IDLE on every heartbeat cycle. - const timeSinceEvent = now - dmrLastEventTime; - if (timeSinceEvent > 5000) { - // No events for 5s — decay target toward idle - dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE); - if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') { - dmrEventType = 'idle'; - updateDmrSynthStatus(); - } - } - - // Smooth approach to target - dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08; - - // Determine effective activity (idle breathing when stopped/idle) - let effectiveActivity = dmrActivityLevel; - if (dmrEventType === 'stopped') { - effectiveActivity = 0; - } else if (effectiveActivity < 0.1 && isDmrRunning) { - // Visible idle breathing — shows decoder is alive and listening - effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06; - } - - // Ripple timing for sync events - const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0; - // Voice ripple overlay - const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0; - - // Update bar targets and physics - for (let i = 0; i < DMR_BAR_COUNT; i++) { - const time = now / 200; - const wave1 = Math.sin(time + i * 0.3) * 0.2; - const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15; - const randomAmount = 0.05 + effectiveActivity * 0.25; - const random = (Math.random() - 0.5) * randomAmount; - - // Bell curve — center bars taller - const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2); - const centerBoost = 1 - centerDist * 0.5; - - // Sync ripple: center-outward wave burst - let rippleBoost = 0; - if (syncRippleAge > 0) { - const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2; - const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos; - rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4; - } - - const baseHeight = 0.1 + effectiveActivity * 0.55; - dmrSynthBars[i].targetHeight = Math.max(2, - (baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) * - effectiveActivity * centerBoost * height - ); - - // Spring physics - const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1; - const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height; - dmrSynthBars[i].velocity += diff * springStrength; - dmrSynthBars[i].velocity *= 0.78; - dmrSynthBars[i].height += dmrSynthBars[i].velocity; - dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height)); - } - - // Draw bars - for (let i = 0; i < DMR_BAR_COUNT; i++) { - const x = i * (barWidth + 2) + 1; - const barHeight = dmrSynthBars[i].height; - const y = (height - barHeight) / 2; - - // HSL color by event type - let hue, saturation, lightness; - if (dmrEventType === 'voice' && timeSinceEvent < 3000) { - hue = 30; // Orange - saturation = 85; - lightness = 40 + (barHeight / height) * 25; - } else if (dmrEventType === 'call' && timeSinceEvent < 3000) { - hue = 120; // Green - saturation = 80; - lightness = 35 + (barHeight / height) * 30; - } else if (dmrEventType === 'sync' && timeSinceEvent < 2000) { - hue = 185; // Cyan - saturation = 85; - lightness = 38 + (barHeight / height) * 25; - } else if (dmrEventType === 'stopped') { - hue = 220; - saturation = 20; - lightness = 18 + (barHeight / height) * 8; - } else { - // Idle / decayed - hue = 210; - saturation = 40; - lightness = 25 + (barHeight / height) * 15; - } - - // Vertical gradient per bar - const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight); - gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`); - gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`); - gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`); - - dmrSynthCtx.fillStyle = gradient; - dmrSynthCtx.fillRect(x, y, barWidth, barHeight); - - // Glow on tall bars - if (barHeight > height * 0.5 && effectiveActivity > 0.4) { - dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`; - dmrSynthCtx.shadowBlur = 8; - dmrSynthCtx.fillRect(x, y, barWidth, barHeight); - dmrSynthCtx.shadowBlur = 0; - } - } - - // Center line - dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)'; - dmrSynthCtx.lineWidth = 1; - dmrSynthCtx.beginPath(); - dmrSynthCtx.moveTo(0, height / 2); - dmrSynthCtx.lineTo(width, height / 2); - dmrSynthCtx.stroke(); - - dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer); -} - -function dmrSynthPulse(type) { - dmrLastEventTime = Date.now(); - - if (type === 'sync') { - dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC); - dmrEventType = 'sync'; - } else if (type === 'call') { - dmrActivityTarget = DMR_BURST_CALL; - dmrEventType = 'call'; - } else if (type === 'voice') { - dmrActivityTarget = DMR_BURST_VOICE; - dmrEventType = 'voice'; - } else if (type === 'slot' || type === 'nac') { - dmrActivityTarget = Math.max(dmrActivityTarget, 0.5); - } else if (type === 'raw') { - // Any DSD output means the decoder is alive and processing - dmrActivityTarget = Math.max(dmrActivityTarget, 0.25); - if (dmrEventType === 'idle') dmrEventType = 'raw'; - } - // keepalive and status don't change visuals - - updateDmrSynthStatus(); -} - -function updateDmrSynthStatus() { - const el = document.getElementById('dmrSynthStatus'); - if (!el) return; - - const labels = { - stopped: 'STOPPED', - idle: 'IDLE', - raw: 'LISTENING', - sync: 'SYNC', - call: 'CALL', - voice: 'VOICE' - }; - const colors = { - stopped: 'var(--text-muted)', - idle: 'var(--text-muted)', - raw: '#607d8b', - sync: '#00e5ff', - call: '#4caf50', - voice: '#ff9800' - }; - - el.textContent = labels[dmrEventType] || 'IDLE'; - el.style.color = colors[dmrEventType] || 'var(--text-muted)'; -} - -function resizeDmrSynthesizer() { - if (!dmrSynthCanvas) return; - const rect = dmrSynthCanvas.getBoundingClientRect(); - if (rect.width > 0) { - dmrSynthCanvas.width = Math.round(rect.width); - dmrSynthCanvas.height = Math.round(rect.height) || 70; - } -} - -function stopDmrSynthesizer() { - if (dmrSynthAnimationId) { - cancelAnimationFrame(dmrSynthAnimationId); - dmrSynthAnimationId = null; - } -} - -window.addEventListener('resize', resizeDmrSynthesizer); - -// ============== AUDIO ============== - -function startDmrAudio() { - const audioPlayer = document.getElementById('dmrAudioPlayer'); - if (!audioPlayer) return; - const streamUrl = `/dmr/audio/stream?t=${Date.now()}`; - audioPlayer.src = streamUrl; - const volSlider = document.getElementById('dmrAudioVolume'); - if (volSlider) audioPlayer.volume = volSlider.value / 100; - - audioPlayer.onplaying = () => updateDmrAudioStatus('STREAMING'); - audioPlayer.onerror = () => { - // Retry if decoder is still running (stream may have dropped) - if (isDmrRunning && dmrHasAudio) { - console.warn('[DMR] Audio stream error, retrying in 2s...'); - updateDmrAudioStatus('RECONNECTING'); - setTimeout(() => { - if (isDmrRunning && dmrHasAudio) startDmrAudio(); - }, 2000); - } else { - updateDmrAudioStatus('OFF'); - } - }; - - audioPlayer.play().catch(e => { - console.warn('[DMR] Audio autoplay blocked:', e); - if (typeof showNotification === 'function') { - showNotification('Audio Ready', 'Click the page or interact to enable audio playback'); - } - }); -} - -function stopDmrAudio() { - const audioPlayer = document.getElementById('dmrAudioPlayer'); - if (audioPlayer) { - audioPlayer.pause(); - audioPlayer.src = ''; - } - dmrHasAudio = false; -} - -function setDmrAudioVolume(value) { - const audioPlayer = document.getElementById('dmrAudioPlayer'); - if (audioPlayer) audioPlayer.volume = value / 100; -} - -function updateDmrAudioStatus(status) { - const el = document.getElementById('dmrAudioStatus'); - if (!el) return; - el.textContent = status; - const colors = { - 'OFF': 'var(--text-muted)', - 'STREAMING': 'var(--accent-green)', - 'ERROR': 'var(--accent-red)', - 'UNAVAILABLE': 'var(--text-muted)', - }; - el.style.color = colors[status] || 'var(--text-muted)'; -} - -// ============== SETTINGS PERSISTENCE ============== - -function restoreDmrSettings() { - try { - const saved = localStorage.getItem(DMR_SETTINGS_KEY); - if (!saved) return; - const s = JSON.parse(saved); - const freqEl = document.getElementById('dmrFrequency'); - const protoEl = document.getElementById('dmrProtocol'); - const gainEl = document.getElementById('dmrGain'); - const ppmEl = document.getElementById('dmrPPM'); - const crcEl = document.getElementById('dmrRelaxCrc'); - if (freqEl && s.frequency != null) freqEl.value = s.frequency; - if (protoEl && s.protocol) protoEl.value = s.protocol; - if (gainEl && s.gain != null) gainEl.value = s.gain; - if (ppmEl && s.ppm != null) ppmEl.value = s.ppm; - if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc; - } catch (e) { /* localStorage unavailable */ } -} - -// ============== BOOKMARKS ============== - -function loadDmrBookmarks() { - try { - const saved = localStorage.getItem(DMR_BOOKMARKS_KEY); - const parsed = saved ? JSON.parse(saved) : []; - if (!Array.isArray(parsed)) { - dmrBookmarks = []; - } else { - dmrBookmarks = parsed - .map((entry) => { - const freq = Number(entry?.freq); - if (!Number.isFinite(freq) || freq <= 0) return null; - const protocol = sanitizeDmrBookmarkProtocol(entry?.protocol); - const rawLabel = String(entry?.label || '').trim(); - const label = rawLabel || `${freq.toFixed(4)} MHz`; - return { - freq, - protocol, - label, - added: entry?.added, - }; - }) - .filter(Boolean); - } - } catch (e) { - dmrBookmarks = []; - } - renderDmrBookmarks(); -} - -function saveDmrBookmarks() { - try { - localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks)); - } catch (e) { /* localStorage unavailable */ } -} - -function sanitizeDmrBookmarkProtocol(protocol) { - const value = String(protocol || 'auto').toLowerCase(); - return DMR_BOOKMARK_PROTOCOLS.has(value) ? value : 'auto'; -} - -function addDmrBookmark() { - const freqInput = document.getElementById('dmrBookmarkFreq'); - const labelInput = document.getElementById('dmrBookmarkLabel'); - if (!freqInput) return; - - const freq = parseFloat(freqInput.value); - if (isNaN(freq) || freq <= 0) { - if (typeof showNotification === 'function') { - showNotification('Invalid Frequency', 'Enter a valid frequency'); - } - return; - } - - const protocol = sanitizeDmrBookmarkProtocol(document.getElementById('dmrProtocol')?.value || 'auto'); - const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`; - - // Duplicate check - if (dmrBookmarks.some(b => b.freq === freq && b.protocol === protocol)) { - if (typeof showNotification === 'function') { - showNotification('Duplicate', 'This frequency/protocol is already bookmarked'); - } - return; - } - - dmrBookmarks.push({ freq, protocol, label, added: new Date().toISOString() }); - saveDmrBookmarks(); - renderDmrBookmarks(); - freqInput.value = ''; - if (labelInput) labelInput.value = ''; - - if (typeof showNotification === 'function') { - showNotification('Bookmark Added', `${freq.toFixed(4)} MHz saved`); - } -} - -function addCurrentDmrFreqBookmark() { - const freqEl = document.getElementById('dmrFrequency'); - const freqInput = document.getElementById('dmrBookmarkFreq'); - if (freqEl && freqInput) { - freqInput.value = freqEl.value; - } - addDmrBookmark(); -} - -function removeDmrBookmark(index) { - dmrBookmarks.splice(index, 1); - saveDmrBookmarks(); - renderDmrBookmarks(); -} - -function dmrQuickTune(freq, protocol) { - const freqEl = document.getElementById('dmrFrequency'); - const protoEl = document.getElementById('dmrProtocol'); - if (freqEl && Number.isFinite(freq)) freqEl.value = freq; - if (protoEl) protoEl.value = sanitizeDmrBookmarkProtocol(protocol); -} - -function renderDmrBookmarks() { - const container = document.getElementById('dmrBookmarksList'); - if (!container) return; - - container.replaceChildren(); - - if (dmrBookmarks.length === 0) { - const emptyEl = document.createElement('div'); - emptyEl.style.color = 'var(--text-muted)'; - emptyEl.style.textAlign = 'center'; - emptyEl.style.padding = '10px'; - emptyEl.style.fontSize = '11px'; - emptyEl.textContent = 'No bookmarks saved'; - container.appendChild(emptyEl); - return; - } - - dmrBookmarks.forEach((b, i) => { - const row = document.createElement('div'); - row.style.display = 'flex'; - row.style.justifyContent = 'space-between'; - row.style.alignItems = 'center'; - row.style.padding = '4px 6px'; - row.style.background = 'rgba(0,0,0,0.2)'; - row.style.borderRadius = '3px'; - row.style.marginBottom = '3px'; - - const tuneBtn = document.createElement('button'); - tuneBtn.type = 'button'; - tuneBtn.style.cursor = 'pointer'; - tuneBtn.style.color = 'var(--accent-cyan)'; - tuneBtn.style.fontSize = '11px'; - tuneBtn.style.flex = '1'; - tuneBtn.style.background = 'none'; - tuneBtn.style.border = 'none'; - tuneBtn.style.textAlign = 'left'; - tuneBtn.style.padding = '0'; - tuneBtn.textContent = b.label; - tuneBtn.title = `${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})`; - tuneBtn.addEventListener('click', () => dmrQuickTune(b.freq, b.protocol)); - - const protocolEl = document.createElement('span'); - protocolEl.style.color = 'var(--text-muted)'; - protocolEl.style.fontSize = '9px'; - protocolEl.style.margin = '0 6px'; - protocolEl.textContent = b.protocol.toUpperCase(); - - const deleteBtn = document.createElement('button'); - deleteBtn.type = 'button'; - deleteBtn.style.background = 'none'; - deleteBtn.style.border = 'none'; - deleteBtn.style.color = 'var(--accent-red)'; - deleteBtn.style.cursor = 'pointer'; - deleteBtn.style.fontSize = '12px'; - deleteBtn.style.padding = '0 4px'; - deleteBtn.textContent = '\u00d7'; - deleteBtn.addEventListener('click', () => removeDmrBookmark(i)); - - row.appendChild(tuneBtn); - row.appendChild(protocolEl); - row.appendChild(deleteBtn); - container.appendChild(row); - }); -} - -// ============== STATUS SYNC ============== - -function checkDmrStatus() { - fetch('/dmr/status') - .then(r => r.json()) - .then(data => { - if (data.running && !isDmrRunning) { - // Backend is running but frontend lost track — resync - isDmrRunning = true; - updateDmrUI(); - connectDmrSSE(); - if (!dmrSynthInitialized) initDmrSynthesizer(); - dmrEventType = 'idle'; - dmrActivityTarget = 0.1; - dmrLastEventTime = Date.now(); - updateDmrSynthStatus(); - const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'DECODING'; - } else if (!data.running && isDmrRunning) { - // Backend stopped but frontend didn't know - isDmrRunning = false; - if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; } - updateDmrUI(); - dmrEventType = 'stopped'; - dmrActivityTarget = 0; - updateDmrSynthStatus(); - const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'STOPPED'; - } - }) - .catch(() => {}); -} - -// ============== INIT ============== - -document.addEventListener('DOMContentLoaded', () => { - restoreDmrSettings(); - loadDmrBookmarks(); -}); - -// ============== EXPORTS ============== - -window.startDmr = startDmr; -window.stopDmr = stopDmr; -window.checkDmrTools = checkDmrTools; -window.checkDmrStatus = checkDmrStatus; -window.initDmrSynthesizer = initDmrSynthesizer; -window.setDmrAudioVolume = setDmrAudioVolume; -window.addDmrBookmark = addDmrBookmark; -window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark; -window.removeDmrBookmark = removeDmrBookmark; -window.dmrQuickTune = dmrQuickTune; diff --git a/static/js/modes/droneops.js b/static/js/modes/droneops.js new file mode 100644 index 0000000..cfc939c --- /dev/null +++ b/static/js/modes/droneops.js @@ -0,0 +1,1418 @@ +/** + * Drone Ops mode frontend. + */ +const DroneOps = (function () { + 'use strict'; + + let initialized = false; + let refreshTimer = null; + let stream = null; + let latestDetections = []; + let latestTracks = []; + let latestCorrelations = []; + let correlationAccess = 'unknown'; + let correlationRefreshCount = 0; + let map = null; + let mapMarkers = null; + let mapTracks = null; + let mapHeat = null; + let mapNeedsAutoFit = true; + let lastCorrelationError = ''; + const DETECTION_START_WAIT_MS = 1500; + const SOURCE_COLORS = { + wifi: '#00d4ff', + bluetooth: '#00ff88', + rf: '#ff9f43', + remote_id: '#f04dff', + }; + + function esc(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function notify(message, isError = false) { + if (typeof SignalCards !== 'undefined' && SignalCards.showToast) { + SignalCards.showToast(message, isError ? 'error' : 'success'); + return; + } + if (typeof showNotification === 'function') { + showNotification(isError ? 'Drone Ops Error' : 'Drone Ops', message); + return; + } + if (isError) { + console.error(message); + } else { + console.log(message); + } + } + + async function api(path, options = {}) { + const response = await fetch(path, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || data.status === 'error') { + const error = new Error(data.message || `Request failed (${response.status})`); + error.status = response.status; + throw error; + } + return data; + } + + async function fetchJson(path, options = {}) { + const response = await fetch(path, options); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.message || data.error || `Request failed (${response.status})`); + } + return data; + } + + async function apiOptional(path, options = {}) { + try { + return await api(path, options); + } catch (error) { + return { __error: error }; + } + } + + function confidenceClass(conf) { + if (conf >= 0.8) return 'ok'; + if (conf >= 0.6) return 'warn'; + return 'bad'; + } + + function toNumber(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + + function hasCoords(lat, lon) { + return lat !== null && lon !== null && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; + } + + function formatCoord(value) { + const num = toNumber(value); + return num === null ? '--' : num.toFixed(5); + } + + function formatMetric(value, decimals = 1, suffix = '') { + const num = toNumber(value); + if (num === null) return '--'; + return `${num.toFixed(decimals)}${suffix}`; + } + + function sourceColor(source) { + return SOURCE_COLORS[String(source || '').toLowerCase()] || '#7f8ea3'; + } + + function defaultMapCenter() { + if (typeof ObserverLocation !== 'undefined' && typeof ObserverLocation.getForModule === 'function') { + const location = ObserverLocation.getForModule('droneops_observerLocation', { fallbackToLatLon: true }); + const lat = toNumber(location?.lat); + const lon = toNumber(location?.lon); + if (hasCoords(lat, lon)) return [lat, lon]; + } + const fallbackLat = toNumber(window.INTERCEPT_DEFAULT_LAT); + const fallbackLon = toNumber(window.INTERCEPT_DEFAULT_LON); + if (hasCoords(fallbackLat, fallbackLon)) return [fallbackLat, fallbackLon]; + return [37.0902, -95.7129]; + } + + function sortedTracksByDetection() { + const grouped = new Map(); + for (const raw of latestTracks) { + const detectionId = Number(raw?.detection_id); + if (!detectionId) continue; + if (!grouped.has(detectionId)) grouped.set(detectionId, []); + grouped.get(detectionId).push(raw); + } + for (const rows of grouped.values()) { + rows.sort((a, b) => String(a?.timestamp || '').localeCompare(String(b?.timestamp || ''))); + } + return grouped; + } + + function detectionTelemetry(detection, tracksByDetection) { + const rows = tracksByDetection.get(Number(detection?.id)) || []; + const latestTrack = rows.length ? rows[rows.length - 1] : null; + const remote = detection && typeof detection.remote_id === 'object' ? detection.remote_id : {}; + const lat = toNumber(latestTrack?.lat ?? remote.lat); + const lon = toNumber(latestTrack?.lon ?? remote.lon); + return { + lat, + lon, + hasPosition: hasCoords(lat, lon), + altitude_m: toNumber(latestTrack?.altitude_m ?? remote.altitude_m), + speed_mps: toNumber(latestTrack?.speed_mps ?? remote.speed_mps), + heading_deg: toNumber(latestTrack?.heading_deg ?? remote.heading_deg), + quality: toNumber(latestTrack?.quality ?? remote.confidence ?? detection?.confidence), + source: latestTrack?.source || detection?.source, + timestamp: latestTrack?.timestamp || detection?.last_seen || '', + uas_id: remote?.uas_id || null, + operator_id: remote?.operator_id || null, + trackRows: rows, + }; + } + + function connectStream() { + if (stream || !initialized) return; + stream = new EventSource('/drone-ops/stream'); + + const handler = (event) => { + let payload = null; + try { + payload = JSON.parse(event.data); + } catch (_) { + return; + } + if (!payload || payload.type === 'keepalive') return; + + if (payload.type === 'detection') { + refreshDetections(); + refreshTracks(); + refreshCorrelations(); + refreshStatus(); + return; + } + + if (payload.type.startsWith('incident_')) { + refreshIncidents(); + refreshStatus(); + return; + } + + if (payload.type.startsWith('action_') || payload.type.startsWith('policy_')) { + refreshActions(); + refreshStatus(); + return; + } + + if (payload.type.startsWith('evidence_')) { + refreshManifests(); + return; + } + }; + + stream.onmessage = handler; + stream.onerror = () => { + if (stream) { + stream.close(); + stream = null; + } + setTimeout(() => { + if (initialized) connectStream(); + }, 2000); + }; + } + + function disconnectStream() { + if (stream) { + stream.close(); + stream = null; + } + } + + function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value; + } + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function isAgentMode() { + return typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + } + + function wifiRunning() { + if (typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) { + return true; + } + if (typeof isWifiRunning !== 'undefined' && isWifiRunning) { + return true; + } + return false; + } + + function bluetoothRunning() { + if (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.isScanning === 'function' && BluetoothMode.isScanning()) { + return true; + } + if (typeof isBtRunning !== 'undefined' && isBtRunning) { + return true; + } + return false; + } + + function updateSensorsState() { + const active = []; + if (wifiRunning()) active.push('WiFi'); + if (bluetoothRunning()) active.push('Bluetooth'); + setText('droneOpsSensorsState', active.length ? `Running (${active.join(' + ')})` : 'Idle'); + } + + function applySelectOptions(selectId, rows, autoLabel) { + const select = document.getElementById(selectId); + if (!select) return; + const previous = String(select.value || ''); + const seen = new Set(); + const options = [``]; + + for (const row of rows) { + const value = String(row?.value || '').trim(); + if (!value || seen.has(value)) continue; + seen.add(value); + const label = String(row?.label || value); + options.push(``); + } + + select.innerHTML = options.join(''); + if (previous && seen.has(previous)) { + select.value = previous; + } + } + + function readExistingSelectOptions(selectId) { + const select = document.getElementById(selectId); + if (!select) return []; + return Array.from(select.options || []) + .map((opt) => ({ + value: String(opt?.value || '').trim(), + label: String(opt?.textContent || opt?.label || '').trim(), + })) + .filter((opt) => opt.value); + } + + function selectHasChoices(selectId) { + return readExistingSelectOptions(selectId).length > 0; + } + + function ensureSelectValue(select, value, label = '') { + if (!select || !value) return; + const target = String(value); + const existing = Array.from(select.options || []).find((opt) => String(opt.value) === target); + if (!existing) { + const option = document.createElement('option'); + option.value = target; + option.textContent = label || target; + select.appendChild(option); + } + select.value = target; + } + + async function fetchWifiSourceOptions() { + if (isAgentMode()) { + const agentId = typeof currentAgent !== 'undefined' ? currentAgent : null; + if (!agentId || agentId === 'local') return []; + const data = await fetchJson(`/controller/agents/${agentId}?refresh=true`); + const rows = data?.agent?.interfaces?.wifi_interfaces || []; + return rows.map((item) => { + if (typeof item === 'string') { + return { value: item, label: item }; + } + const value = String(item?.name || item?.id || '').trim(); + if (!value) return null; + let label = String(item?.display_name || value); + if (!item?.display_name && item?.type) label += ` (${item.type})`; + if (item?.monitor_capable || item?.supports_monitor) label += ' [Monitor OK]'; + return { value, label }; + }).filter(Boolean); + } + + let rows = []; + try { + const data = await fetchJson('/wifi/interfaces'); + rows = data?.interfaces || []; + } catch (_) { + rows = []; + } + + const mapped = rows.map((item) => { + const value = String(item?.name || item?.id || '').trim(); + if (!value) return null; + let label = value; + const details = []; + if (item?.chipset) details.push(item.chipset); + else if (item?.driver) details.push(item.driver); + if (details.length) label += ` - ${details.join(' | ')}`; + if (item?.type) label += ` (${item.type})`; + if (item?.monitor_capable || item?.supports_monitor) label += ' [Monitor OK]'; + return { value, label }; + }).filter(Boolean); + + if (mapped.length) return mapped; + + if (typeof refreshWifiInterfaces === 'function') { + try { + await Promise.resolve(refreshWifiInterfaces()); + await sleep(250); + } catch (_) { + // Fall back to currently populated options. + } + } + + return readExistingSelectOptions('wifiInterfaceSelect'); + } + + async function fetchBluetoothSourceOptions() { + if (isAgentMode()) { + const agentId = typeof currentAgent !== 'undefined' ? currentAgent : null; + if (!agentId || agentId === 'local') return []; + const data = await fetchJson(`/controller/agents/${agentId}?refresh=true`); + const rows = data?.agent?.interfaces?.bt_adapters || []; + return rows.map((item) => { + if (typeof item === 'string') { + return { value: item, label: item }; + } + const value = String(item?.id || item?.name || '').trim(); + if (!value) return null; + let label = item?.name && item.name !== value ? `${value} - ${item.name}` : value; + if (item?.powered === false) label += ' [DOWN]'; + else if (item?.powered === true) label += ' [UP]'; + return { value, label }; + }).filter(Boolean); + } + + let rows = []; + try { + const data = await fetchJson('/api/bluetooth/capabilities'); + rows = data?.adapters || []; + } catch (_) { + rows = []; + } + + const mapped = rows.map((item) => { + const value = String(item?.id || item?.name || '').trim(); + if (!value) return null; + let label = item?.name && item.name !== value ? `${value} - ${item.name}` : value; + if (item?.powered === false) label += ' [DOWN]'; + else if (item?.powered === true) label += ' [UP]'; + return { value, label }; + }).filter(Boolean); + + if (mapped.length) return mapped; + + if (typeof refreshBtInterfaces === 'function') { + try { + await Promise.resolve(refreshBtInterfaces()); + await sleep(250); + } catch (_) { + // Fall back to currently populated options. + } + } + + return readExistingSelectOptions('btAdapterSelect'); + } + + function applySelectedSourceToModeSelectors() { + const wifiChosen = String(document.getElementById('droneOpsWifiInterfaceSelect')?.value || '').trim(); + if (wifiChosen) { + const wifiSelect = document.getElementById('wifiInterfaceSelect'); + ensureSelectValue(wifiSelect, wifiChosen, wifiChosen); + // Force fresh monitor-interface derivation for the selected adapter. + if (typeof monitorInterface !== 'undefined' && monitorInterface && monitorInterface !== wifiChosen) { + monitorInterface = null; + } + } + + const btChosen = String(document.getElementById('droneOpsBtAdapterSelect')?.value || '').trim(); + if (btChosen) { + const btSelect = document.getElementById('btAdapterSelect'); + ensureSelectValue(btSelect, btChosen, btChosen); + } + } + + async function refreshDetectionSources(silent = false) { + const wifiSelect = document.getElementById('droneOpsWifiInterfaceSelect'); + const btSelect = document.getElementById('droneOpsBtAdapterSelect'); + if (wifiSelect) wifiSelect.innerHTML = ''; + if (btSelect) btSelect.innerHTML = ''; + + const [wifiResult, btResult] = await Promise.allSettled([ + fetchWifiSourceOptions(), + fetchBluetoothSourceOptions(), + ]); + + if (wifiResult.status === 'fulfilled') { + applySelectOptions('droneOpsWifiInterfaceSelect', wifiResult.value, 'Auto WiFi source'); + } else { + applySelectOptions('droneOpsWifiInterfaceSelect', [], 'Auto WiFi source'); + if (!silent) notify(`WiFi source refresh failed: ${wifiResult.reason?.message || 'unknown error'}`, true); + } + + if (btResult.status === 'fulfilled') { + applySelectOptions('droneOpsBtAdapterSelect', btResult.value, 'Auto Bluetooth source'); + } else { + applySelectOptions('droneOpsBtAdapterSelect', [], 'Auto Bluetooth source'); + if (!silent) notify(`Bluetooth source refresh failed: ${btResult.reason?.message || 'unknown error'}`, true); + } + + applySelectedSourceToModeSelectors(); + if (!silent && wifiResult.status === 'fulfilled' && btResult.status === 'fulfilled') { + notify('Detection sources refreshed'); + } + } + + function addFallbackMapLayer(targetMap) { + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 19, + className: 'tile-layer-cyan', + }).addTo(targetMap); + } + + async function ensureMap() { + if (map || typeof L === 'undefined') return; + const mapEl = document.getElementById('droneOpsMap'); + if (!mapEl) return; + + map = L.map(mapEl, { + center: defaultMapCenter(), + zoom: 12, + minZoom: 2, + maxZoom: 19, + zoomControl: true, + }); + + if (typeof Settings !== 'undefined' && typeof Settings.createTileLayer === 'function') { + try { + await Settings.init(); + Settings.createTileLayer().addTo(map); + if (typeof Settings.registerMap === 'function') { + Settings.registerMap(map); + } + } catch (_) { + addFallbackMapLayer(map); + } + } else { + addFallbackMapLayer(map); + } + + mapTracks = L.layerGroup().addTo(map); + mapMarkers = L.layerGroup().addTo(map); + if (typeof L.heatLayer === 'function') { + mapHeat = L.heatLayer([], { + radius: 18, + blur: 16, + minOpacity: 0.28, + maxZoom: 18, + gradient: { + 0.2: '#00b7ff', + 0.45: '#00ff88', + 0.7: '#ffb400', + 1.0: '#ff355e', + }, + }).addTo(map); + } + setTimeout(() => { + if (map) map.invalidateSize(); + }, 120); + } + + function invalidateMap() { + mapNeedsAutoFit = true; + if (!map) { + ensureMap(); + return; + } + [80, 240, 500].forEach((delay) => { + setTimeout(() => { + if (map) map.invalidateSize(); + }, delay); + }); + } + + function renderMainSummary(detections, tracksByDetection, filteredTrackCount) { + const sources = new Set(); + let telemetryCount = 0; + + for (const d of detections) { + const source = String(d?.source || '').trim().toLowerCase(); + if (source) sources.add(source); + const telemetry = detectionTelemetry(d, tracksByDetection); + if (telemetry.hasPosition || telemetry.altitude_m !== null || telemetry.speed_mps !== null || telemetry.heading_deg !== null) { + telemetryCount += 1; + } + } + + setText('droneOpsMainSummaryDetections', String(detections.length)); + setText('droneOpsMainSummarySources', String(sources.size)); + setText('droneOpsMainSummaryTracks', String(filteredTrackCount)); + setText('droneOpsMainSummaryTelemetry', String(telemetryCount)); + if (correlationAccess === 'restricted') { + setText('droneOpsMainSummaryCorrelations', 'Role'); + } else { + setText('droneOpsMainSummaryCorrelations', String(latestCorrelations.length)); + } + setText('droneOpsMainLastUpdate', new Date().toLocaleTimeString()); + } + + function renderMainDetections(detections, tracksByDetection) { + const container = document.getElementById('droneOpsMainDetections'); + if (!container) return; + + if (!detections.length) { + container.innerHTML = '
No detections yet
'; + return; + } + + container.innerHTML = detections.slice(0, 150).map((d) => { + const conf = Number(d.confidence || 0); + const cls = confidenceClass(conf); + const telemetry = detectionTelemetry(d, tracksByDetection); + const ridSummary = []; + if (telemetry.uas_id) ridSummary.push(`uas: ${esc(telemetry.uas_id)}`); + if (telemetry.operator_id) ridSummary.push(`operator: ${esc(telemetry.operator_id)}`); + + return `
+
+ ${esc(d.identifier)} + ${Math.round(conf * 100)}% +
+
+ source: ${esc(d.source || 'unknown')} + class: ${esc(d.classification || 'unknown')} + last: ${esc(d.last_seen || '')} +
+
+ lat: ${formatCoord(telemetry.lat)} + lon: ${formatCoord(telemetry.lon)} + alt: ${formatMetric(telemetry.altitude_m, 1, ' m')} + spd: ${formatMetric(telemetry.speed_mps, 1, ' m/s')} + hdg: ${formatMetric(telemetry.heading_deg, 0, '°')} +
+ ${ridSummary.length ? `
${ridSummary.join(' • ')}
` : ''} +
`; + }).join(''); + } + + function renderMainTelemetry(detections, filteredTracks) { + const container = document.getElementById('droneOpsMainTelemetry'); + if (!container) return; + + const detectionById = new Map(detections.map((d) => [Number(d.id), d])); + + if (filteredTracks.length) { + const rows = filteredTracks + .slice() + .sort((a, b) => String(b?.timestamp || '').localeCompare(String(a?.timestamp || ''))) + .slice(0, 180); + + container.innerHTML = rows.map((t) => { + const detection = detectionById.get(Number(t.detection_id)); + const label = detection?.identifier || `#${Number(t.detection_id)}`; + const quality = toNumber(t.quality); + return `
+ ${esc(label)} ${esc(t.timestamp || '')} + ${formatCoord(t.lat)}, ${formatCoord(t.lon)} + alt ${formatMetric(t.altitude_m, 1, 'm')} + spd ${formatMetric(t.speed_mps, 1, 'm/s')} + hdg ${formatMetric(t.heading_deg, 0, '°')} + ${esc(t.source || detection?.source || 'unknown')} • q ${quality === null ? '--' : quality.toFixed(2)} +
`; + }).join(''); + return; + } + + const tracksByDetection = sortedTracksByDetection(); + const telemetryRows = detections + .map((d) => ({ detection: d, telemetry: detectionTelemetry(d, tracksByDetection) })) + .filter((entry) => entry.telemetry.hasPosition || entry.telemetry.altitude_m !== null || entry.telemetry.speed_mps !== null || entry.telemetry.heading_deg !== null); + + if (!telemetryRows.length) { + container.innerHTML = '
No telemetry yet
'; + return; + } + + container.innerHTML = telemetryRows.slice(0, 120).map((entry) => { + const d = entry.detection; + const telemetry = entry.telemetry; + return `
+ ${esc(d.identifier)} ${esc(telemetry.timestamp || '')} + ${formatCoord(telemetry.lat)}, ${formatCoord(telemetry.lon)} + alt ${formatMetric(telemetry.altitude_m, 1, 'm')} + spd ${formatMetric(telemetry.speed_mps, 1, 'm/s')} + hdg ${formatMetric(telemetry.heading_deg, 0, '°')} + ${esc(d.source || 'unknown')} +
`; + }).join(''); + } + + function renderMainCorrelations() { + const container = document.getElementById('droneOpsMainCorrelations'); + if (!container) return; + + if (correlationAccess === 'restricted') { + container.innerHTML = '
Correlation data requires analyst role
'; + return; + } + + if (correlationAccess === 'error') { + container.innerHTML = '
Correlation data unavailable
'; + return; + } + + if (!latestCorrelations.length) { + container.innerHTML = '
No correlations yet
'; + return; + } + + container.innerHTML = latestCorrelations.slice(0, 80).map((row) => { + const confidence = Number(row?.confidence || 0); + const cls = confidenceClass(confidence); + return `
+ ${esc(row.drone_identifier || 'unknown')} → ${esc(row.operator_identifier || 'unknown')} + method: ${esc(row.method || 'n/a')} • ${esc(row.created_at || '')} + ${Math.round(confidence * 100)}% +
`; + }).join(''); + } + + function renderMapDetections(detections, tracksByDetection) { + if (!map) { + if (typeof L === 'undefined' || !document.getElementById('droneOpsMap')) return; + ensureMap().then(() => { + if (map) renderMapDetections(detections, tracksByDetection); + }).catch(() => {}); + return; + } + if (!mapMarkers || !mapTracks) return; + + mapMarkers.clearLayers(); + mapTracks.clearLayers(); + + const heatPoints = []; + const boundsPoints = []; + + for (const d of detections) { + const telemetry = detectionTelemetry(d, tracksByDetection); + const color = sourceColor(telemetry.source || d.source); + const pathPoints = []; + for (const row of telemetry.trackRows) { + const lat = toNumber(row?.lat); + const lon = toNumber(row?.lon); + if (!hasCoords(lat, lon)) continue; + const latLng = [lat, lon]; + pathPoints.push(latLng); + boundsPoints.push(latLng); + const intensity = Math.max(0.2, Math.min(1, toNumber(row?.quality ?? d.confidence) ?? 0.5)); + heatPoints.push([lat, lon, intensity]); + } + + if (pathPoints.length > 1) { + L.polyline(pathPoints, { + color, + weight: 2, + opacity: 0.75, + lineJoin: 'round', + }).addTo(mapTracks); + } + + if (telemetry.hasPosition) { + const latLng = [telemetry.lat, telemetry.lon]; + boundsPoints.push(latLng); + const intensity = Math.max(0.2, Math.min(1, telemetry.quality ?? toNumber(d.confidence) ?? 0.5)); + heatPoints.push([telemetry.lat, telemetry.lon, intensity]); + + L.circleMarker(latLng, { + radius: 6, + color, + fillColor: color, + fillOpacity: 0.88, + weight: 2, + }).bindPopup(` +
+
${esc(d.identifier)}
+
Source: ${esc(d.source || 'unknown')}
+
Confidence: ${Math.round(Number(d.confidence || 0) * 100)}%
+
Lat/Lon: ${formatCoord(telemetry.lat)}, ${formatCoord(telemetry.lon)}
+
Alt: ${formatMetric(telemetry.altitude_m, 1, ' m')} • Spd: ${formatMetric(telemetry.speed_mps, 1, ' m/s')}
+
Heading: ${formatMetric(telemetry.heading_deg, 0, '°')}
+
+ `).addTo(mapMarkers); + } + } + + if (mapHeat && typeof mapHeat.setLatLngs === 'function') { + mapHeat.setLatLngs(heatPoints); + } + + if (boundsPoints.length && mapNeedsAutoFit) { + map.fitBounds(L.latLngBounds(boundsPoints), { padding: [24, 24], maxZoom: 16 }); + mapNeedsAutoFit = false; + } + + if (!boundsPoints.length) { + setText('droneOpsMapMeta', 'No geospatial telemetry yet'); + } else { + setText('droneOpsMapMeta', `${boundsPoints.length} geo points • ${heatPoints.length} heat samples`); + } + } + + function renderMainPane() { + const pane = document.getElementById('droneOpsMainPane'); + if (!pane) return; + + const detections = Array.isArray(latestDetections) ? latestDetections : []; + const detectionIds = new Set(detections.map((d) => Number(d.id)).filter(Boolean)); + const filteredTracks = (Array.isArray(latestTracks) ? latestTracks : []) + .filter((track) => detectionIds.has(Number(track?.detection_id))); + const tracksByDetection = sortedTracksByDetection(); + + renderMainSummary(detections, tracksByDetection, filteredTracks.length); + renderMainDetections(detections, tracksByDetection); + renderMainTelemetry(detections, filteredTracks); + renderMainCorrelations(); + renderMapDetections(detections, tracksByDetection); + } + + async function ensureSessionForDetection() { + try { + const status = await api('/drone-ops/status'); + if (!status.active_session) { + await api('/drone-ops/session/start', { + method: 'POST', + body: JSON.stringify({ mode: 'passive' }), + }); + } + } catch (_) { + // Detection can still run without an explicit Drone Ops session. + } + } + + async function startWifiDetection() { + if (isAgentMode()) { + if (typeof WiFiMode !== 'undefined') { + if (WiFiMode.init) WiFiMode.init(); + if (WiFiMode.startDeepScan) { + await WiFiMode.startDeepScan(); + await sleep(DETECTION_START_WAIT_MS); + if (wifiRunning()) return; + } + } + throw new Error('Unable to start WiFi detection in agent mode'); + } + + if (typeof WiFiMode !== 'undefined') { + if (WiFiMode.init) WiFiMode.init(); + if (WiFiMode.startDeepScan) { + await WiFiMode.startDeepScan(); + await sleep(DETECTION_START_WAIT_MS); + if (wifiRunning()) return; + } + } + + if (typeof startWifiScan === 'function') { + await Promise.resolve(startWifiScan()); + await sleep(DETECTION_START_WAIT_MS); + if (wifiRunning()) return; + } + + throw new Error('WiFi scan did not start'); + } + + async function startBluetoothDetection() { + if (typeof startBtScan === 'function') { + await Promise.resolve(startBtScan()); + await sleep(DETECTION_START_WAIT_MS); + if (bluetoothRunning()) return; + } + + if (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.startScan === 'function') { + await BluetoothMode.startScan(); + await sleep(DETECTION_START_WAIT_MS); + if (bluetoothRunning()) return; + } + + throw new Error('Bluetooth scan did not start'); + } + + async function stopWifiDetection() { + if (isAgentMode() && typeof WiFiMode !== 'undefined' && typeof WiFiMode.stopScan === 'function') { + await WiFiMode.stopScan(); + return; + } + + if (typeof stopWifiScan === 'function') { + await Promise.resolve(stopWifiScan()); + return; + } + + if (typeof WiFiMode !== 'undefined' && typeof WiFiMode.stopScan === 'function') { + await WiFiMode.stopScan(); + return; + } + } + + async function stopBluetoothDetection() { + if (typeof stopBtScan === 'function') { + await Promise.resolve(stopBtScan()); + return; + } + + if (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.stopScan === 'function') { + await BluetoothMode.stopScan(); + return; + } + } + + async function refreshStatus() { + try { + const data = await api('/drone-ops/status'); + const active = data.active_session; + const policy = data.policy || {}; + const counts = data.counts || {}; + + setText('droneOpsSessionValue', active ? `${active.mode.toUpperCase()} #${active.id}` : 'Idle'); + setText('droneOpsArmedValue', policy.armed ? 'Yes' : 'No'); + setText('droneOpsDetectionCount', String(counts.detections || 0)); + setText('droneOpsIncidentCount', String(counts.incidents_open || 0)); + updateSensorsState(); + } catch (e) { + notify(e.message, true); + } + } + + async function refreshDetections() { + const source = document.getElementById('droneOpsSourceFilter')?.value || ''; + const min = parseFloat(document.getElementById('droneOpsConfidenceFilter')?.value || '0.5'); + + try { + const data = await api(`/drone-ops/detections?limit=500&source=${encodeURIComponent(source)}&min_confidence=${encodeURIComponent(isNaN(min) ? 0.5 : min)}`); + const detections = data.detections || []; + latestDetections = detections; + const container = document.getElementById('droneOpsDetections'); + if (!container) { + renderMainPane(); + return; + } + + if (!detections.length) { + container.innerHTML = '
No detections yet
'; + renderMainPane(); + return; + } + + container.innerHTML = detections.map((d) => { + const conf = Number(d.confidence || 0); + const confPct = Math.round(conf * 100); + const cls = confidenceClass(conf); + return `
+
${esc(d.identifier)} ${confPct}%
+
+ source: ${esc(d.source)} + class: ${esc(d.classification || 'unknown')} + last seen: ${esc(d.last_seen || '')} +
+
+ +
+
`; + }).join(''); + renderMainPane(); + } catch (e) { + notify(e.message, true); + } + } + + async function refreshTracks() { + try { + const data = await api('/drone-ops/tracks?limit=3000'); + latestTracks = Array.isArray(data.tracks) ? data.tracks : []; + renderMainPane(); + } catch (e) { + notify(e.message, true); + } + } + + async function refreshCorrelations() { + const refreshFlag = correlationRefreshCount % 4 === 0; + correlationRefreshCount += 1; + const result = await apiOptional(`/drone-ops/correlations?min_confidence=0.5&limit=120&refresh=${refreshFlag ? 'true' : 'false'}`); + if (result.__error) { + if (result.__error?.status === 403) { + correlationAccess = 'restricted'; + latestCorrelations = []; + renderMainPane(); + return; + } + correlationAccess = 'error'; + latestCorrelations = []; + const message = String(result.__error?.message || 'Unable to load correlations'); + if (message !== lastCorrelationError) { + lastCorrelationError = message; + notify(message, true); + } + renderMainPane(); + return; + } + + correlationAccess = 'ok'; + lastCorrelationError = ''; + latestCorrelations = Array.isArray(result.correlations) ? result.correlations : []; + renderMainPane(); + } + + async function refreshIncidents() { + try { + const data = await api('/drone-ops/incidents?limit=100'); + const incidents = data.incidents || []; + const container = document.getElementById('droneOpsIncidents'); + if (!container) return; + + if (!incidents.length) { + container.innerHTML = '
No incidents
'; + return; + } + + container.innerHTML = incidents.map((i) => ` +
+
#${Number(i.id)} ${esc(i.title)} + ${esc(i.status)} +
+
+ severity: ${esc(i.severity)} + opened: ${esc(i.opened_at || '')} +
+
+ + +
+
+ `).join(''); + } catch (e) { + notify(e.message, true); + } + } + + async function refreshActions() { + try { + const data = await api('/drone-ops/actions/requests?limit=100'); + const rows = data.requests || []; + const container = document.getElementById('droneOpsActions'); + if (!container) return; + + if (!rows.length) { + container.innerHTML = '
No action requests
'; + return; + } + + container.innerHTML = rows.map((r) => { + const statusClass = r.status === 'executed' ? 'ok' : (r.status === 'approved' ? 'warn' : 'bad'); + return `
+
Request #${Number(r.id)} + ${esc(r.status)} +
+
+ incident: ${Number(r.incident_id)} + action: ${esc(r.action_type)} + requested by: ${esc(r.requested_by)} +
+
+ + +
+
`; + }).join(''); + } catch (e) { + notify(e.message, true); + } + } + + async function refreshManifests() { + const incident = parseInt(document.getElementById('droneOpsManifestIncident')?.value || '0', 10); + const container = document.getElementById('droneOpsManifests'); + if (!container) return; + + if (!incident) { + container.innerHTML = '
Enter incident ID to list manifests
'; + return; + } + + try { + const data = await api(`/drone-ops/evidence/${incident}/manifests?limit=50`); + const rows = data.manifests || []; + if (!rows.length) { + container.innerHTML = '
No manifests
'; + return; + } + container.innerHTML = rows.map((m) => `
+
Manifest #${Number(m.id)}
+
+ algo: ${esc(m.hash_algo)} + created: ${esc(m.created_at || '')} +
+
`).join(''); + } catch (e) { + notify(e.message, true); + } + } + + async function refreshAll() { + await Promise.all([ + refreshStatus(), + refreshDetections(), + refreshTracks(), + refreshCorrelations(), + refreshIncidents(), + refreshActions(), + refreshManifests(), + ]); + renderMainPane(); + } + + async function startSession(mode) { + try { + await api('/drone-ops/session/start', { + method: 'POST', + body: JSON.stringify({ mode }), + }); + notify(`Started ${mode} session`); + refreshStatus(); + } catch (e) { + notify(e.message, true); + } + } + + async function stopSession() { + try { + await api('/drone-ops/session/stop', { method: 'POST', body: JSON.stringify({}) }); + notify('Session stopped'); + refreshStatus(); + } catch (e) { + notify(e.message, true); + } + } + + async function arm() { + const incident = parseInt(document.getElementById('droneOpsArmIncident')?.value || '0', 10); + const reason = String(document.getElementById('droneOpsArmReason')?.value || '').trim(); + if (!incident || !reason) { + notify('Incident ID and arming reason are required', true); + return; + } + try { + await api('/drone-ops/actions/arm', { + method: 'POST', + body: JSON.stringify({ incident_id: incident, reason }), + }); + notify('Action plane armed'); + refreshStatus(); + } catch (e) { + notify(e.message, true); + } + } + + async function disarm() { + try { + await api('/drone-ops/actions/disarm', { method: 'POST', body: JSON.stringify({}) }); + notify('Action plane disarmed'); + refreshStatus(); + } catch (e) { + notify(e.message, true); + } + } + + async function startDetection() { + const useWifi = Boolean(document.getElementById('droneOpsDetectWifi')?.checked); + const useBluetooth = Boolean(document.getElementById('droneOpsDetectBluetooth')?.checked); + + if (!useWifi && !useBluetooth) { + notify('Select at least one source (WiFi or Bluetooth)', true); + return; + } + + const needsWifiSources = useWifi && !selectHasChoices('droneOpsWifiInterfaceSelect'); + const needsBtSources = useBluetooth && !selectHasChoices('droneOpsBtAdapterSelect'); + if (needsWifiSources || needsBtSources) { + await refreshDetectionSources(true); + } + + applySelectedSourceToModeSelectors(); + await ensureSessionForDetection(); + + const started = []; + const failed = []; + + if (useWifi) { + try { + await startWifiDetection(); + started.push('WiFi'); + } catch (e) { + failed.push(`WiFi: ${e.message}`); + } + } + + if (useBluetooth) { + try { + await startBluetoothDetection(); + started.push('Bluetooth'); + } catch (e) { + failed.push(`Bluetooth: ${e.message}`); + } + } + + updateSensorsState(); + await refreshStatus(); + await refreshDetections(); + await refreshTracks(); + await refreshCorrelations(); + + if (!started.length) { + notify(`Detection start failed (${failed.join(' | ')})`, true); + return; + } + + if (failed.length) { + notify(`Started: ${started.join(', ')} | Errors: ${failed.join(' | ')}`, true); + return; + } + + notify(`Detection started: ${started.join(', ')}`); + } + + async function stopDetection() { + const errors = []; + + if (wifiRunning()) { + try { + await stopWifiDetection(); + } catch (e) { + errors.push(`WiFi: ${e.message}`); + } + } + + if (bluetoothRunning()) { + try { + await stopBluetoothDetection(); + } catch (e) { + errors.push(`Bluetooth: ${e.message}`); + } + } + + await sleep(300); + updateSensorsState(); + await refreshStatus(); + await refreshTracks(); + + if (errors.length) { + notify(`Detection stop issues: ${errors.join(' | ')}`, true); + return; + } + notify('Detection stopped'); + } + + async function createIncident() { + const title = String(document.getElementById('droneOpsIncidentTitle')?.value || '').trim(); + const severity = String(document.getElementById('droneOpsIncidentSeverity')?.value || 'medium'); + if (!title) { + notify('Incident title is required', true); + return; + } + + try { + const data = await api('/drone-ops/incidents', { + method: 'POST', + body: JSON.stringify({ title, severity }), + }); + notify(`Incident #${data.incident?.id || ''} created`); + refreshIncidents(); + refreshStatus(); + } catch (e) { + notify(e.message, true); + } + } + + async function closeIncident(incidentId) { + try { + await api(`/drone-ops/incidents/${incidentId}`, { + method: 'PUT', + body: JSON.stringify({ status: 'closed' }), + }); + notify(`Incident #${incidentId} closed`); + refreshIncidents(); + refreshStatus(); + } catch (e) { + notify(e.message, true); + } + } + + async function openIncidentFromDetection(detectionId) { + const title = `Drone detection #${detectionId}`; + try { + const created = await api('/drone-ops/incidents', { + method: 'POST', + body: JSON.stringify({ title, severity: 'medium' }), + }); + const incidentId = created.incident.id; + await api(`/drone-ops/incidents/${incidentId}/artifacts`, { + method: 'POST', + body: JSON.stringify({ + artifact_type: 'detection', + artifact_ref: String(detectionId), + metadata: { auto_linked: true }, + }), + }); + notify(`Incident #${incidentId} opened for detection #${detectionId}`); + refreshIncidents(); + refreshStatus(); + } catch (e) { + notify(e.message, true); + } + } + + async function attachLatestDetections(incidentId) { + try { + const data = await api('/drone-ops/detections?limit=10&min_confidence=0.6'); + const detections = data.detections || []; + if (!detections.length) { + notify('No high-confidence detections to attach', true); + return; + } + for (const d of detections) { + await api(`/drone-ops/incidents/${incidentId}/artifacts`, { + method: 'POST', + body: JSON.stringify({ + artifact_type: 'detection', + artifact_ref: String(d.id), + metadata: { source: d.source, identifier: d.identifier }, + }), + }); + } + notify(`Attached ${detections.length} detections to incident #${incidentId}`); + refreshIncidents(); + } catch (e) { + notify(e.message, true); + } + } + + async function requestAction() { + const incident = parseInt(document.getElementById('droneOpsActionIncident')?.value || '0', 10); + const actionType = String(document.getElementById('droneOpsActionType')?.value || '').trim(); + + if (!incident || !actionType) { + notify('Incident ID and action type are required', true); + return; + } + + try { + await api('/drone-ops/actions/request', { + method: 'POST', + body: JSON.stringify({ + incident_id: incident, + action_type: actionType, + payload: {}, + }), + }); + notify('Action request submitted'); + refreshActions(); + } catch (e) { + notify(e.message, true); + } + } + + async function approveAction(requestId) { + try { + await api(`/drone-ops/actions/approve/${requestId}`, { + method: 'POST', + body: JSON.stringify({ decision: 'approved' }), + }); + notify(`Request #${requestId} approved`); + refreshActions(); + } catch (e) { + notify(e.message, true); + } + } + + async function executeAction(requestId) { + try { + await api(`/drone-ops/actions/execute/${requestId}`, { + method: 'POST', + body: JSON.stringify({}), + }); + notify(`Request #${requestId} executed`); + refreshActions(); + } catch (e) { + notify(e.message, true); + } + } + + async function generateManifest() { + const incident = parseInt(document.getElementById('droneOpsManifestIncident')?.value || '0', 10); + if (!incident) { + notify('Incident ID is required to generate manifest', true); + return; + } + try { + await api(`/drone-ops/evidence/${incident}/manifest`, { + method: 'POST', + body: JSON.stringify({}), + }); + notify(`Manifest generated for incident #${incident}`); + refreshManifests(); + } catch (e) { + notify(e.message, true); + } + } + + function init() { + if (initialized) { + refreshDetectionSources(true); + refreshAll(); + invalidateMap(); + return; + } + initialized = true; + mapNeedsAutoFit = true; + ensureMap(); + refreshDetectionSources(true); + refreshAll(); + connectStream(); + refreshTimer = setInterval(refreshAll, 15000); + } + + function destroy() { + initialized = false; + mapNeedsAutoFit = true; + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + disconnectStream(); + } + + return { + init, + destroy, + refreshStatus, + refreshDetections, + refreshTracks, + refreshDetectionSources, + refreshIncidents, + refreshActions, + startDetection, + stopDetection, + invalidateMap, + startSession, + stopSession, + arm, + disarm, + createIncident, + closeIncident, + openIncidentFromDetection, + attachLatestDetections, + requestAction, + approveAction, + executeAction, + generateManifest, + }; +})(); diff --git a/templates/index.html b/templates/index.html index 477c053..f677cb4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -66,6 +66,7 @@ aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}", tscm: "{{ url_for('static', filename='css/modes/tscm.css') }}", analytics: "{{ url_for('static', filename='css/modes/analytics.css') }}", + droneops: "{{ url_for('static', filename='css/modes/droneops.css') }}?v={{ version }}&r=droneops6", spystations: "{{ url_for('static', filename='css/modes/spy-stations.css') }}", meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}", sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}", @@ -281,6 +282,10 @@ TSCM + - -
- - -
-
-
No bookmarks saved
-
- - - -
-

Current Call

-
-
No active call
-
-
- - -
-

Status

-
-
- Status - IDLE -
-
- Protocol - -- -
-
- Calls - 0 -
-
-
- -
- - -
- diff --git a/templates/partials/modes/droneops.html b/templates/partials/modes/droneops.html new file mode 100644 index 0000000..817c4a6 --- /dev/null +++ b/templates/partials/modes/droneops.html @@ -0,0 +1,152 @@ + +
+
+

+ Drone Ops Status + +

+
+
+
+
Session
+
Idle
+
+
+
Armed
+
No
+
+
+
Detections
+
0
+
+
+
Open Incidents
+
0
+
+
+ +
+ + + +
+ +
+ + + + +
+ +
+
Detection Sources
+
+ + + +
+
+
Sensors: Idle
+ +
+ + + + +
+
+
+ +
+

+ Detections + +

+
+
+ + + +
+
+
No detections yet
+
+
+
+ +
+

+ Incidents + +

+
+
+ + + +
+
+
No incidents
+
+
+
+ +
+

+ Actions + +

+
+
+ + + +
+
+
No action requests
+
+
+
+ +
+

+ Evidence + +

+
+
+ + +
+
+
No manifests
+
+
+
+
diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 0e5d3f5..f5140fa 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -133,6 +133,7 @@
{{ mode_item('tscm', 'TSCM', '') }} + {{ mode_item('droneops', 'Drone Ops', '') }} {{ mode_item('analytics', 'Analytics', '') }} {{ mode_item('spystations', 'Spy Stations', '') }} {{ mode_item('websdr', 'WebSDR', '') }} @@ -215,6 +216,7 @@ {{ mobile_item('meshtastic', 'Mesh', '') }} {# Intel #} {{ mobile_item('tscm', 'TSCM', '') }} + {{ mobile_item('droneops', 'Drone Ops', '') }} {{ mobile_item('analytics', 'Analytics', '') }} {{ mobile_item('spystations', 'Spy', '') }} {{ mobile_item('websdr', 'WebSDR', '') }} diff --git a/tests/test_dmr.py b/tests/test_dmr.py deleted file mode 100644 index 6a683d4..0000000 --- a/tests/test_dmr.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Tests for the DMR / Digital Voice decoding module.""" - -import queue -from unittest.mock import patch, MagicMock -import pytest -import routes.dmr as dmr_module -from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION - - -# ============================================ -# parse_dsd_output() tests -# ============================================ - -def test_parse_sync_dmr(): - """Should parse DMR sync line.""" - result = parse_dsd_output('Sync: +DMR (data)') - assert result is not None - assert result['type'] == 'sync' - assert 'DMR' in result['protocol'] - - -def test_parse_sync_p25(): - """Should parse P25 sync line.""" - result = parse_dsd_output('Sync: +P25 Phase 1') - assert result is not None - assert result['type'] == 'sync' - assert 'P25' in result['protocol'] - - -def test_parse_talkgroup_and_source(): - """Should parse talkgroup and source ID.""" - result = parse_dsd_output('TG: 12345 Src: 67890') - assert result is not None - assert result['type'] == 'call' - assert result['talkgroup'] == 12345 - assert result['source_id'] == 67890 - - -def test_parse_slot(): - """Should parse slot info.""" - result = parse_dsd_output('Slot 1') - assert result is not None - assert result['type'] == 'slot' - assert result['slot'] == 1 - - -def test_parse_voice(): - """Should parse voice frame info.""" - result = parse_dsd_output('Voice Frame 1') - assert result is not None - assert result['type'] == 'voice' - - -def test_parse_nac(): - """Should parse P25 NAC.""" - result = parse_dsd_output('NAC: 293') - assert result is not None - assert result['type'] == 'nac' - assert result['nac'] == '293' - - -def test_parse_talkgroup_dsd_fme_format(): - """Should parse dsd-fme comma-separated TG/Src format.""" - result = parse_dsd_output('TG: 12345, Src: 67890') - assert result is not None - assert result['type'] == 'call' - assert result['talkgroup'] == 12345 - assert result['source_id'] == 67890 - - -def test_parse_talkgroup_dsd_fme_tgt_src_format(): - """Should parse dsd-fme TGT/SRC pipe-delimited format.""" - result = parse_dsd_output('Slot 1 | TGT: 12345 | SRC: 67890') - assert result is not None - assert result['type'] == 'call' - assert result['talkgroup'] == 12345 - assert result['source_id'] == 67890 - assert result['slot'] == 1 - - -def test_parse_talkgroup_with_slot(): - """TG line with slot info should capture both.""" - result = parse_dsd_output('Slot 1 Voice LC, TG: 100, Src: 200') - assert result is not None - assert result['type'] == 'call' - assert result['talkgroup'] == 100 - assert result['source_id'] == 200 - assert result['slot'] == 1 - - -def test_parse_voice_with_slot(): - """Voice frame with slot info should be voice, not slot.""" - result = parse_dsd_output('Slot 2 Voice Frame') - assert result is not None - assert result['type'] == 'voice' - assert result['slot'] == 2 - - -def test_parse_empty_line(): - """Empty lines should return None.""" - assert parse_dsd_output('') is None - assert parse_dsd_output(' ') is None - - -def test_parse_unrecognized(): - """Unrecognized lines should return raw event for diagnostics.""" - result = parse_dsd_output('some random text') - assert result is not None - assert result['type'] == 'raw' - assert result['text'] == 'some random text' - - -def test_parse_banner_filtered(): - """Pure box-drawing lines (banners) should be filtered.""" - assert parse_dsd_output('╔══════════════╗') is None - assert parse_dsd_output('║ ║') is None - assert parse_dsd_output('╚══════════════╝') is None - assert parse_dsd_output('───────────────') is None - - -def test_parse_box_drawing_with_data_not_filtered(): - """Lines with box-drawing separators AND data should NOT be filtered.""" - result = parse_dsd_output('DMR BS │ Slot 1 │ TG: 12345 │ SRC: 67890') - assert result is not None - assert result['type'] == 'call' - assert result['talkgroup'] == 12345 - assert result['source_id'] == 67890 - - -def test_dsd_fme_flags_differ_from_classic(): - """dsd-fme remapped several flags; tables must NOT be identical.""" - assert _DSD_FME_PROTOCOL_FLAGS != _DSD_PROTOCOL_FLAGS - - -def test_dsd_fme_protocol_flags_known_values(): - """dsd-fme flags use its own flag names (NOT classic DSD mappings).""" - assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-fa'] # Broad auto - assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!) - assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-ft'] # P25 P1/P2 coverage - assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn'] - assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme - assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv - - -def test_dsd_protocol_flags_known_values(): - """Classic DSD protocol flags should map to the correct -f flags.""" - assert _DSD_PROTOCOL_FLAGS['dmr'] == ['-fd'] - assert _DSD_PROTOCOL_FLAGS['p25'] == ['-fp'] - assert _DSD_PROTOCOL_FLAGS['nxdn'] == ['-fn'] - assert _DSD_PROTOCOL_FLAGS['dstar'] == ['-fi'] - assert _DSD_PROTOCOL_FLAGS['provoice'] == ['-fv'] - assert _DSD_PROTOCOL_FLAGS['auto'] == [] - - -def test_dsd_fme_modulation_hints(): - """C4FM modulation hints should be set for C4FM protocols.""" - assert _DSD_FME_MODULATION['dmr'] == ['-mc'] - assert _DSD_FME_MODULATION['nxdn'] == ['-mc'] - # P25, D-Star and ProVoice should not have forced modulation - assert 'p25' not in _DSD_FME_MODULATION - assert 'dstar' not in _DSD_FME_MODULATION - assert 'provoice' not in _DSD_FME_MODULATION - - -# ============================================ -# Endpoint tests -# ============================================ - -@pytest.fixture -def auth_client(client): - """Client with logged-in session.""" - with client.session_transaction() as sess: - sess['logged_in'] = True - return client - - -@pytest.fixture(autouse=True) -def reset_dmr_globals(): - """Reset DMR globals before/after each test to avoid cross-test bleed.""" - dmr_module.dmr_rtl_process = None - dmr_module.dmr_dsd_process = None - dmr_module.dmr_thread = None - dmr_module.dmr_running = False - dmr_module.dmr_has_audio = False - dmr_module.dmr_active_device = None - with dmr_module._ffmpeg_sinks_lock: - dmr_module._ffmpeg_sinks.clear() - try: - while True: - dmr_module.dmr_queue.get_nowait() - except queue.Empty: - pass - - yield - - dmr_module.dmr_rtl_process = None - dmr_module.dmr_dsd_process = None - dmr_module.dmr_thread = None - dmr_module.dmr_running = False - dmr_module.dmr_has_audio = False - dmr_module.dmr_active_device = None - with dmr_module._ffmpeg_sinks_lock: - dmr_module._ffmpeg_sinks.clear() - try: - while True: - dmr_module.dmr_queue.get_nowait() - except queue.Empty: - pass - - -def test_dmr_tools(auth_client): - """Tools endpoint should return availability info.""" - resp = auth_client.get('/dmr/tools') - assert resp.status_code == 200 - data = resp.get_json() - assert 'dsd' in data - assert 'rtl_fm' in data - assert 'protocols' in data - - -def test_dmr_status(auth_client): - """Status endpoint should work.""" - resp = auth_client.get('/dmr/status') - assert resp.status_code == 200 - data = resp.get_json() - assert 'running' in data - - -def test_dmr_start_no_dsd(auth_client): - """Start should fail gracefully when dsd is not installed.""" - with patch('routes.dmr.find_dsd', return_value=(None, False)): - resp = auth_client.post('/dmr/start', json={ - 'frequency': 462.5625, - 'protocol': 'auto', - }) - assert resp.status_code == 503 - data = resp.get_json() - assert 'dsd' in data['message'] - - -def test_dmr_start_no_rtl_fm(auth_client): - """Start should fail when rtl_fm is missing.""" - with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \ - patch('routes.dmr.find_rtl_fm', return_value=None): - resp = auth_client.post('/dmr/start', json={ - 'frequency': 462.5625, - }) - assert resp.status_code == 503 - - -def test_dmr_start_invalid_protocol(auth_client): - """Start should reject invalid protocol.""" - with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \ - patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'): - resp = auth_client.post('/dmr/start', json={ - 'frequency': 462.5625, - 'protocol': 'invalid', - }) - assert resp.status_code == 400 - - -def test_dmr_stop(auth_client): - """Stop should succeed.""" - resp = auth_client.post('/dmr/stop') - assert resp.status_code == 200 - data = resp.get_json() - assert data['status'] == 'stopped' - - -def test_dmr_stream_mimetype(auth_client): - """Stream should return event-stream content type.""" - resp = auth_client.get('/dmr/stream') - assert resp.content_type.startswith('text/event-stream') - - -def test_dmr_start_exception_cleans_up_resources(auth_client): - """If startup fails after rtl_fm launch, process/device state should be reset.""" - rtl_proc = MagicMock() - rtl_proc.poll.return_value = None - rtl_proc.wait.return_value = 0 - rtl_proc.stdout = MagicMock() - rtl_proc.stderr = MagicMock() - - builder = MagicMock() - builder.build_fm_demod_command.return_value = ['rtl_fm', '-f', '462.5625M'] - - with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \ - patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'), \ - patch('routes.dmr.find_ffmpeg', return_value=None), \ - patch('routes.dmr.SDRFactory.create_default_device', return_value=MagicMock()), \ - patch('routes.dmr.SDRFactory.get_builder', return_value=builder), \ - patch('routes.dmr.app_module.claim_sdr_device', return_value=None), \ - patch('routes.dmr.app_module.release_sdr_device') as release_mock, \ - patch('routes.dmr.register_process') as register_mock, \ - patch('routes.dmr.unregister_process') as unregister_mock, \ - patch('routes.dmr.subprocess.Popen', side_effect=[rtl_proc, RuntimeError('dsd launch failed')]): - resp = auth_client.post('/dmr/start', json={ - 'frequency': 462.5625, - 'protocol': 'auto', - 'device': 0, - }) - - assert resp.status_code == 500 - assert 'dsd launch failed' in resp.get_json()['message'] - register_mock.assert_called_once_with(rtl_proc) - rtl_proc.terminate.assert_called_once() - unregister_mock.assert_called_once_with(rtl_proc) - release_mock.assert_called_once_with(0) - assert dmr_module.dmr_running is False - assert dmr_module.dmr_rtl_process is None - assert dmr_module.dmr_dsd_process is None diff --git a/tests/test_drone_ops_policy.py b/tests/test_drone_ops_policy.py new file mode 100644 index 0000000..a58c2ba --- /dev/null +++ b/tests/test_drone_ops_policy.py @@ -0,0 +1,36 @@ +"""Tests for Drone Ops policy helpers and service policy behavior.""" + +from utils.drone.policy import required_approvals_for_action +from utils.drone.service import DroneOpsService + + +def test_required_approvals_policy_helper(): + assert required_approvals_for_action('passive_scan') == 1 + assert required_approvals_for_action('wifi_deauth_test') == 2 + + +def test_service_required_approvals_matches_policy_helper(): + assert DroneOpsService.required_approvals('passive_capture') == required_approvals_for_action('passive_capture') + assert DroneOpsService.required_approvals('active_test') == required_approvals_for_action('active_test') + + +def test_service_arm_disarm_policy_state(): + service = DroneOpsService() + + armed = service.arm_actions( + actor='operator-1', + reason='controlled testing', + incident_id=42, + duration_seconds=5, + ) + assert armed['armed'] is True + assert armed['armed_by'] == 'operator-1' + assert armed['arm_reason'] == 'controlled testing' + assert armed['arm_incident_id'] == 42 + assert armed['armed_until'] is not None + + disarmed = service.disarm_actions(actor='operator-1', reason='test complete') + assert disarmed['armed'] is False + assert disarmed['armed_by'] is None + assert disarmed['arm_reason'] is None + assert disarmed['arm_incident_id'] is None diff --git a/tests/test_drone_ops_remote_id.py b/tests/test_drone_ops_remote_id.py new file mode 100644 index 0000000..7bd5a36 --- /dev/null +++ b/tests/test_drone_ops_remote_id.py @@ -0,0 +1,60 @@ +"""Tests for Drone Ops Remote ID decoder helpers.""" + +from __future__ import annotations + +import json + +from utils.drone.remote_id import decode_remote_id_payload + + +def test_decode_remote_id_from_dict_payload(): + payload = { + 'remote_id': { + 'uas_id': 'UAS-001', + 'operator_id': 'OP-007', + 'lat': 37.7749, + 'lon': -122.4194, + 'altitude_m': 121.5, + 'speed_mps': 12.3, + 'heading_deg': 270.0, + } + } + + decoded = decode_remote_id_payload(payload) + assert decoded['detected'] is True + assert decoded['source_format'] == 'dict' + assert decoded['uas_id'] == 'UAS-001' + assert decoded['operator_id'] == 'OP-007' + assert decoded['lat'] == 37.7749 + assert decoded['lon'] == -122.4194 + assert decoded['altitude_m'] == 121.5 + assert decoded['speed_mps'] == 12.3 + assert decoded['heading_deg'] == 270.0 + assert decoded['confidence'] > 0.0 + + +def test_decode_remote_id_from_json_string(): + payload = json.dumps({ + 'uas_id': 'RID-ABC', + 'lat': 35.0, + 'lon': -115.0, + 'altitude': 80, + }) + + decoded = decode_remote_id_payload(payload) + assert decoded['detected'] is True + assert decoded['source_format'] == 'json' + assert decoded['uas_id'] == 'RID-ABC' + assert decoded['lat'] == 35.0 + assert decoded['lon'] == -115.0 + assert decoded['altitude_m'] == 80.0 + + +def test_decode_remote_id_from_raw_text_is_not_detected(): + decoded = decode_remote_id_payload('not-a-remote-id-payload') + assert decoded['detected'] is False + assert decoded['source_format'] == 'raw' + assert decoded['uas_id'] is None + assert decoded['operator_id'] is None + assert isinstance(decoded['raw'], dict) + assert decoded['raw']['raw'] == 'not-a-remote-id-payload' diff --git a/tests/test_drone_ops_routes.py b/tests/test_drone_ops_routes.py new file mode 100644 index 0000000..28e8f84 --- /dev/null +++ b/tests/test_drone_ops_routes.py @@ -0,0 +1,228 @@ +"""Tests for Drone Ops API routes.""" + +from __future__ import annotations + +import pytest + +import utils.database as db_mod +from utils.drone import get_drone_ops_service + + +def _set_identity(client, role: str, username: str = 'tester') -> None: + with client.session_transaction() as sess: + sess['logged_in'] = True + sess['role'] = role + sess['username'] = username + + +def _clear_drone_tables() -> None: + with db_mod.get_db() as conn: + conn.execute('DELETE FROM action_audit_log') + conn.execute('DELETE FROM action_approvals') + conn.execute('DELETE FROM action_requests') + conn.execute('DELETE FROM evidence_manifests') + conn.execute('DELETE FROM drone_incident_artifacts') + conn.execute('DELETE FROM drone_tracks') + conn.execute('DELETE FROM drone_correlations') + conn.execute('DELETE FROM drone_detections') + conn.execute('DELETE FROM drone_incidents') + conn.execute('DELETE FROM drone_sessions') + + +@pytest.fixture(scope='module', autouse=True) +def isolated_drone_db(tmp_path_factory): + original_db_dir = db_mod.DB_DIR + original_db_path = db_mod.DB_PATH + + tmp_dir = tmp_path_factory.mktemp('drone_ops_db') + db_mod.DB_DIR = tmp_dir + db_mod.DB_PATH = tmp_dir / 'test_intercept.db' + + if hasattr(db_mod._local, 'connection') and db_mod._local.connection is not None: + db_mod._local.connection.close() + db_mod._local.connection = None + + db_mod.init_db() + yield + + db_mod.close_db() + db_mod.DB_DIR = original_db_dir + db_mod.DB_PATH = original_db_path + db_mod._local.connection = None + + +@pytest.fixture(autouse=True) +def clean_drone_state(): + db_mod.init_db() + _clear_drone_tables() + get_drone_ops_service().disarm_actions(actor='test-reset', reason='test setup') + yield + _clear_drone_tables() + get_drone_ops_service().disarm_actions(actor='test-reset', reason='test teardown') + + +def test_start_session_requires_operator_role(client): + _set_identity(client, role='viewer') + response = client.post('/drone-ops/session/start', json={'mode': 'passive'}) + assert response.status_code == 403 + data = response.get_json() + assert data['required_role'] == 'operator' + + +def test_session_lifecycle_and_status(client): + _set_identity(client, role='operator', username='op1') + + started = client.post('/drone-ops/session/start', json={'mode': 'passive'}) + assert started.status_code == 200 + start_data = started.get_json() + assert start_data['status'] == 'success' + assert start_data['session']['mode'] == 'passive' + assert start_data['session']['active'] is True + + status = client.get('/drone-ops/status') + assert status.status_code == 200 + status_data = status.get_json() + assert status_data['status'] == 'success' + assert status_data['active_session'] is not None + assert status_data['active_session']['id'] == start_data['session']['id'] + + stopped = client.post('/drone-ops/session/stop', json={'id': start_data['session']['id']}) + assert stopped.status_code == 200 + stop_data = stopped.get_json() + assert stop_data['status'] == 'success' + assert stop_data['session']['active'] is False + + +def test_detection_ingest_visible_via_endpoint(client): + _set_identity(client, role='operator', username='op1') + start_resp = client.post('/drone-ops/session/start', json={'mode': 'passive'}) + assert start_resp.status_code == 200 + + service = get_drone_ops_service() + service.ingest_event( + mode='wifi', + event={ + 'bssid': '60:60:1F:AA:BB:CC', + 'ssid': 'DJI-OPS-TEST', + }, + event_type='network_update', + ) + + _set_identity(client, role='viewer', username='viewer1') + response = client.get('/drone-ops/detections?source=wifi&min_confidence=0.5') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + assert data['count'] >= 1 + detection = data['detections'][0] + assert detection['source'] == 'wifi' + assert detection['confidence'] >= 0.5 + + +def test_incident_artifact_and_manifest_flow(client): + _set_identity(client, role='operator', username='op1') + created = client.post( + '/drone-ops/incidents', + json={'title': 'Unidentified UAS', 'severity': 'high'}, + ) + assert created.status_code == 201 + incident = created.get_json()['incident'] + incident_id = incident['id'] + + artifact_resp = client.post( + f'/drone-ops/incidents/{incident_id}/artifacts', + json={'artifact_type': 'detection', 'artifact_ref': '12345'}, + ) + assert artifact_resp.status_code == 201 + + _set_identity(client, role='analyst', username='analyst1') + manifest_resp = client.post(f'/drone-ops/evidence/{incident_id}/manifest', json={}) + assert manifest_resp.status_code == 201 + manifest = manifest_resp.get_json()['manifest'] + assert manifest['manifest']['integrity']['algorithm'] == 'sha256' + assert len(manifest['manifest']['integrity']['digest']) == 64 + + _set_identity(client, role='viewer', username='viewer1') + listed = client.get(f'/drone-ops/evidence/{incident_id}/manifests') + assert listed.status_code == 200 + listed_data = listed.get_json() + assert listed_data['count'] == 1 + assert listed_data['manifests'][0]['id'] == manifest['id'] + + +def test_action_execution_requires_arming_and_two_approvals(client): + _set_identity(client, role='operator', username='op1') + incident_resp = client.post('/drone-ops/incidents', json={'title': 'Action Gate Test'}) + incident_id = incident_resp.get_json()['incident']['id'] + + request_resp = client.post( + '/drone-ops/actions/request', + json={ + 'incident_id': incident_id, + 'action_type': 'wifi_deauth_test', + 'payload': {'target': 'aa:bb:cc:dd:ee:ff'}, + }, + ) + assert request_resp.status_code == 201 + request_id = request_resp.get_json()['request']['id'] + + not_armed_resp = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) + assert not_armed_resp.status_code == 403 + assert 'not armed' in not_armed_resp.get_json()['message'].lower() + + arm_resp = client.post( + '/drone-ops/actions/arm', + json={'incident_id': incident_id, 'reason': 'controlled test'}, + ) + assert arm_resp.status_code == 200 + assert arm_resp.get_json()['policy']['armed'] is True + + insufficient_resp = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) + assert insufficient_resp.status_code == 400 + assert 'insufficient approvals' in insufficient_resp.get_json()['message'].lower() + + _set_identity(client, role='supervisor', username='supervisor-a') + approve_one = client.post(f'/drone-ops/actions/approve/{request_id}', json={'decision': 'approved'}) + assert approve_one.status_code == 200 + + _set_identity(client, role='operator', username='op1') + still_insufficient = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) + assert still_insufficient.status_code == 400 + + _set_identity(client, role='supervisor', username='supervisor-b') + approve_two = client.post(f'/drone-ops/actions/approve/{request_id}', json={'decision': 'approved'}) + assert approve_two.status_code == 200 + assert approve_two.get_json()['request']['status'] == 'approved' + + _set_identity(client, role='operator', username='op1') + executed = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) + assert executed.status_code == 200 + assert executed.get_json()['request']['status'] == 'executed' + + +def test_passive_action_executes_after_single_approval(client): + _set_identity(client, role='operator', username='op1') + incident_resp = client.post('/drone-ops/incidents', json={'title': 'Passive Action Test'}) + incident_id = incident_resp.get_json()['incident']['id'] + + request_resp = client.post( + '/drone-ops/actions/request', + json={'incident_id': incident_id, 'action_type': 'passive_spectrum_capture'}, + ) + request_id = request_resp.get_json()['request']['id'] + + arm_resp = client.post( + '/drone-ops/actions/arm', + json={'incident_id': incident_id, 'reason': 'passive validation'}, + ) + assert arm_resp.status_code == 200 + + _set_identity(client, role='supervisor', username='supervisor-a') + approve_resp = client.post(f'/drone-ops/actions/approve/{request_id}', json={'decision': 'approved'}) + assert approve_resp.status_code == 200 + assert approve_resp.get_json()['request']['status'] == 'approved' + + _set_identity(client, role='operator', username='op1') + execute_resp = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) + assert execute_resp.status_code == 200 + assert execute_resp.get_json()['request']['status'] == 'executed' diff --git a/tests/test_routes.py b/tests/test_routes.py index 51aafc2..0ea7dbf 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -58,17 +58,6 @@ class TestHealthEndpoint: assert 'wifi' in processes assert 'bluetooth' in processes - def test_health_reports_dmr_route_process(self, client): - """Health should reflect DMR route module state (not stale app globals).""" - mock_proc = MagicMock() - mock_proc.poll.return_value = None - with patch('routes.dmr.dmr_running', True), \ - patch('routes.dmr.dmr_dsd_process', mock_proc): - response = client.get('/health') - data = json.loads(response.data) - assert data['processes']['dmr'] is True - - class TestDevicesEndpoint: """Tests for devices endpoint.""" diff --git a/utils/analytics.py b/utils/analytics.py index 6481bc6..205e0e3 100644 --- a/utils/analytics.py +++ b/utils/analytics.py @@ -122,14 +122,14 @@ def _get_mode_counts() -> dict[str, int]: except Exception: counts['aprs'] = 0 - # Meshtastic recent messages (route-level list) - try: - import routes.meshtastic as mesh_route - counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', [])) - except Exception: - counts['meshtastic'] = 0 - - return counts + # Meshtastic recent messages (route-level list) + try: + import routes.meshtastic as mesh_route + counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', [])) + except Exception: + counts['meshtastic'] = 0 + + return counts def get_cross_mode_summary() -> dict[str, Any]: @@ -160,12 +160,11 @@ def get_mode_health() -> dict[str, dict]: 'acars': 'acars_process', 'vdl2': 'vdl2_process', 'aprs': 'aprs_process', - 'wifi': 'wifi_process', - 'bluetooth': 'bt_process', - 'dsc': 'dsc_process', - 'rtlamr': 'rtlamr_process', - 'dmr': 'dmr_process', - } + 'wifi': 'wifi_process', + 'bluetooth': 'bt_process', + 'dsc': 'dsc_process', + 'rtlamr': 'rtlamr_process', + } for mode, attr in process_map.items(): proc = getattr(app_module, attr, None) @@ -187,16 +186,16 @@ def get_mode_health() -> dict[str, dict]: pass # Meshtastic: check client connection status - try: - from utils.meshtastic import get_meshtastic_client - client = get_meshtastic_client() - health['meshtastic'] = {'running': client._interface is not None} - except Exception: - health['meshtastic'] = {'running': False} - - try: - sdr_status = app_module.get_sdr_device_status() - health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()} + try: + from utils.meshtastic import get_meshtastic_client + client = get_meshtastic_client() + health['meshtastic'] = {'running': client._interface is not None} + except Exception: + health['meshtastic'] = {'running': False} + + try: + sdr_status = app_module.get_sdr_device_status() + health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()} except Exception: health['sdr_devices'] = {} diff --git a/utils/authz.py b/utils/authz.py new file mode 100644 index 0000000..086ca27 --- /dev/null +++ b/utils/authz.py @@ -0,0 +1,74 @@ +"""Authorization helpers for role-based and arming-gated operations.""" + +from __future__ import annotations + +from functools import wraps +from typing import Any, Callable + +from flask import jsonify, session + +ROLE_LEVELS: dict[str, int] = { + 'viewer': 10, + 'analyst': 20, + 'operator': 30, + 'supervisor': 40, + 'admin': 50, +} + + +def current_username() -> str: + """Get current username from session.""" + return str(session.get('username') or 'anonymous') + + +def current_role() -> str: + """Get current role from session with safe default.""" + role = str(session.get('role') or 'viewer').strip().lower() + return role if role in ROLE_LEVELS else 'viewer' + + +def has_role(required_role: str) -> bool: + """Return True if current session role satisfies required role.""" + required = ROLE_LEVELS.get(required_role, ROLE_LEVELS['admin']) + actual = ROLE_LEVELS.get(current_role(), ROLE_LEVELS['viewer']) + return actual >= required + + +def require_role(required_role: str) -> Callable: + """Decorator enforcing minimum role.""" + + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args: Any, **kwargs: Any): + if not has_role(required_role): + return jsonify({ + 'status': 'error', + 'message': f'{required_role} role required', + 'required_role': required_role, + 'current_role': current_role(), + }), 403 + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def require_armed(func: Callable) -> Callable: + """Decorator enforcing armed state for active actions.""" + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any): + from utils.drone import get_drone_ops_service + + service = get_drone_ops_service() + policy = service.get_policy_state() + if not policy.get('armed'): + return jsonify({ + 'status': 'error', + 'message': 'Action plane is not armed', + 'policy': policy, + }), 403 + return func(*args, **kwargs) + + return wrapper diff --git a/utils/database.py b/utils/database.py index c56890a..373fa68 100644 --- a/utils/database.py +++ b/utils/database.py @@ -550,12 +550,178 @@ def init_db() -> None: INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) VALUES ('25544', 'ISS (ZARYA)', NULL, NULL, 1, 1) ''') - conn.execute(''' - INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) - VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1) - ''') - - logger.info("Database initialized successfully") + conn.execute(''' + INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) + VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1) + ''') + + # ===================================================================== + # Drone Ops / Professional Ops Tables + # ===================================================================== + + conn.execute(''' + CREATE TABLE IF NOT EXISTS drone_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mode TEXT NOT NULL DEFAULT 'passive', + label TEXT, + operator TEXT, + metadata TEXT, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + stopped_at TIMESTAMP, + summary TEXT + ) + ''') + + conn.execute(''' + CREATE TABLE IF NOT EXISTS drone_detections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER, + source TEXT NOT NULL, + identifier TEXT NOT NULL, + classification TEXT, + confidence REAL DEFAULT 0, + first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + payload_json TEXT, + remote_id_json TEXT, + FOREIGN KEY (session_id) REFERENCES drone_sessions(id) ON DELETE SET NULL, + UNIQUE(session_id, source, identifier) + ) + ''') + + conn.execute(''' + CREATE TABLE IF NOT EXISTS drone_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + detection_id INTEGER NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + lat REAL, + lon REAL, + altitude_m REAL, + speed_mps REAL, + heading_deg REAL, + quality REAL, + source TEXT, + FOREIGN KEY (detection_id) REFERENCES drone_detections(id) ON DELETE CASCADE + ) + ''') + + conn.execute(''' + CREATE TABLE IF NOT EXISTS drone_correlations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + drone_identifier TEXT, + operator_identifier TEXT, + method TEXT, + confidence REAL, + evidence_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + conn.execute(''' + CREATE TABLE IF NOT EXISTS drone_incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + status TEXT DEFAULT 'open', + severity TEXT DEFAULT 'medium', + opened_by TEXT, + opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP, + summary TEXT, + metadata TEXT + ) + ''') + + conn.execute(''' + CREATE TABLE IF NOT EXISTS drone_incident_artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incident_id INTEGER NOT NULL, + artifact_type TEXT NOT NULL, + artifact_ref TEXT NOT NULL, + added_by TEXT, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + metadata TEXT, + FOREIGN KEY (incident_id) REFERENCES drone_incidents(id) ON DELETE CASCADE + ) + ''') + + conn.execute(''' + CREATE TABLE IF NOT EXISTS action_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incident_id INTEGER NOT NULL, + action_type TEXT NOT NULL, + requested_by TEXT NOT NULL, + requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'pending', + payload_json TEXT, + executed_at TIMESTAMP, + executed_by TEXT, + FOREIGN KEY (incident_id) REFERENCES drone_incidents(id) ON DELETE CASCADE + ) + ''') + + conn.execute(''' + CREATE TABLE IF NOT EXISTS action_approvals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL, + approved_by TEXT NOT NULL, + approved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + decision TEXT NOT NULL, + notes TEXT, + FOREIGN KEY (request_id) REFERENCES action_requests(id) ON DELETE CASCADE + ) + ''') + + conn.execute(''' + CREATE TABLE IF NOT EXISTS action_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER, + event_type TEXT NOT NULL, + actor TEXT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + details_json TEXT, + FOREIGN KEY (request_id) REFERENCES action_requests(id) ON DELETE SET NULL + ) + ''') + + conn.execute(''' + CREATE TABLE IF NOT EXISTS evidence_manifests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incident_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + hash_algo TEXT DEFAULT 'sha256', + manifest_json TEXT NOT NULL, + signature TEXT, + created_by TEXT, + FOREIGN KEY (incident_id) REFERENCES drone_incidents(id) ON DELETE CASCADE + ) + ''') + + conn.execute(''' + CREATE INDEX IF NOT EXISTS idx_drone_detections_last_seen + ON drone_detections(last_seen, confidence) + ''') + conn.execute(''' + CREATE INDEX IF NOT EXISTS idx_drone_tracks_detection_time + ON drone_tracks(detection_id, timestamp) + ''') + conn.execute(''' + CREATE INDEX IF NOT EXISTS idx_drone_incidents_status + ON drone_incidents(status, opened_at) + ''') + conn.execute(''' + CREATE INDEX IF NOT EXISTS idx_action_requests_status + ON action_requests(status, requested_at) + ''') + conn.execute(''' + CREATE INDEX IF NOT EXISTS idx_action_approvals_request + ON action_approvals(request_id, approved_at) + ''') + conn.execute(''' + CREATE INDEX IF NOT EXISTS idx_evidence_manifests_incident + ON evidence_manifests(incident_id, created_at) + ''') + + logger.info("Database initialized successfully") def close_db() -> None: @@ -2285,10 +2451,10 @@ def update_tracked_satellite(norad_id: str, enabled: bool) -> bool: return cursor.rowcount > 0 -def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]: - """Delete a tracked satellite by NORAD ID. Refuses to delete builtins.""" - with get_db() as conn: - row = conn.execute( +def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]: + """Delete a tracked satellite by NORAD ID. Refuses to delete builtins.""" + with get_db() as conn: + row = conn.execute( 'SELECT builtin FROM tracked_satellites WHERE norad_id = ?', (str(norad_id),), ).fetchone() @@ -2296,9 +2462,813 @@ def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]: return False, 'Satellite not found' if row[0]: return False, 'Cannot remove builtin satellite' - conn.execute( - 'DELETE FROM tracked_satellites WHERE norad_id = ?', - (str(norad_id),), - ) - return True, 'Removed' + conn.execute( + 'DELETE FROM tracked_satellites WHERE norad_id = ?', + (str(norad_id),), + ) + return True, 'Removed' + + +# ============================================================================= +# Drone Ops / Professional Ops Functions +# ============================================================================= + +def _decode_json(raw: str | None, default: Any = None) -> Any: + """Decode JSON safely with fallback default.""" + if raw is None or raw == '': + return default + try: + return json.loads(raw) + except (TypeError, json.JSONDecodeError): + return default + + +def create_drone_session( + mode: str = 'passive', + label: str | None = None, + operator: str | None = None, + metadata: dict | None = None, +) -> int: + """Create a Drone Ops session and return its ID.""" + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO drone_sessions (mode, label, operator, metadata) + VALUES (?, ?, ?, ?) + ''', (mode, label, operator, json.dumps(metadata) if metadata else None)) + return int(cursor.lastrowid) + + +def stop_drone_session(session_id: int, summary: dict | None = None) -> bool: + """Stop an active Drone Ops session.""" + with get_db() as conn: + cursor = conn.execute(''' + UPDATE drone_sessions + SET stopped_at = CURRENT_TIMESTAMP, summary = ? + WHERE id = ? AND stopped_at IS NULL + ''', (json.dumps(summary) if summary else None, session_id)) + return cursor.rowcount > 0 + + +def get_drone_session(session_id: int) -> dict | None: + """Get Drone Ops session by ID.""" + with get_db() as conn: + row = conn.execute( + 'SELECT * FROM drone_sessions WHERE id = ?', + (session_id,), + ).fetchone() + if not row: + return None + return { + 'id': row['id'], + 'mode': row['mode'], + 'label': row['label'], + 'operator': row['operator'], + 'metadata': _decode_json(row['metadata'], {}), + 'started_at': row['started_at'], + 'stopped_at': row['stopped_at'], + 'summary': _decode_json(row['summary'], {}), + 'active': row['stopped_at'] is None, + } + + +def get_active_drone_session() -> dict | None: + """Return currently active Drone Ops session, if any.""" + with get_db() as conn: + row = conn.execute(''' + SELECT * FROM drone_sessions + WHERE stopped_at IS NULL + ORDER BY started_at DESC + LIMIT 1 + ''').fetchone() + if not row: + return None + return { + 'id': row['id'], + 'mode': row['mode'], + 'label': row['label'], + 'operator': row['operator'], + 'metadata': _decode_json(row['metadata'], {}), + 'started_at': row['started_at'], + 'stopped_at': row['stopped_at'], + 'summary': _decode_json(row['summary'], {}), + 'active': True, + } + + +def list_drone_sessions(limit: int = 50, active_only: bool = False) -> list[dict]: + """List Drone Ops sessions.""" + query = ''' + SELECT * FROM drone_sessions + ''' + params: list[Any] = [] + if active_only: + query += ' WHERE stopped_at IS NULL' + query += ' ORDER BY started_at DESC LIMIT ?' + params.append(limit) + + with get_db() as conn: + rows = conn.execute(query, params).fetchall() + return [ + { + 'id': row['id'], + 'mode': row['mode'], + 'label': row['label'], + 'operator': row['operator'], + 'metadata': _decode_json(row['metadata'], {}), + 'started_at': row['started_at'], + 'stopped_at': row['stopped_at'], + 'summary': _decode_json(row['summary'], {}), + 'active': row['stopped_at'] is None, + } + for row in rows + ] + + +def upsert_drone_detection( + session_id: int | None, + source: str, + identifier: str, + classification: str | None = None, + confidence: float = 0.0, + payload: dict | None = None, + remote_id: dict | None = None, +) -> int: + """Insert or update a Drone Ops detection, returning detection ID.""" + source = (source or '').strip().lower() + identifier = (identifier or '').strip() + if not source or not identifier: + raise ValueError('source and identifier are required') + + with get_db() as conn: + if session_id is None: + row = conn.execute(''' + SELECT id FROM drone_detections + WHERE session_id IS NULL AND source = ? AND identifier = ? + ''', (source, identifier)).fetchone() + else: + row = conn.execute(''' + SELECT id FROM drone_detections + WHERE session_id = ? AND source = ? AND identifier = ? + ''', (session_id, source, identifier)).fetchone() + + payload_json = json.dumps(payload) if payload is not None else None + remote_id_json = json.dumps(remote_id) if remote_id is not None else None + + if row: + detection_id = int(row['id']) + conn.execute(''' + UPDATE drone_detections + SET + classification = COALESCE(?, classification), + confidence = CASE WHEN ? > confidence THEN ? ELSE confidence END, + last_seen = CURRENT_TIMESTAMP, + payload_json = COALESCE(?, payload_json), + remote_id_json = COALESCE(?, remote_id_json) + WHERE id = ? + ''', ( + classification, + confidence, + confidence, + payload_json, + remote_id_json, + detection_id, + )) + return detection_id + + cursor = conn.execute(''' + INSERT INTO drone_detections + (session_id, source, identifier, classification, confidence, payload_json, remote_id_json) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + session_id, + source, + identifier, + classification, + confidence, + payload_json, + remote_id_json, + )) + return int(cursor.lastrowid) + + +def get_drone_detection(detection_id: int) -> dict | None: + """Get a Drone Ops detection by ID.""" + with get_db() as conn: + row = conn.execute( + 'SELECT * FROM drone_detections WHERE id = ?', + (detection_id,), + ).fetchone() + if not row: + return None + return { + 'id': row['id'], + 'session_id': row['session_id'], + 'source': row['source'], + 'identifier': row['identifier'], + 'classification': row['classification'], + 'confidence': float(row['confidence'] or 0.0), + 'first_seen': row['first_seen'], + 'last_seen': row['last_seen'], + 'payload': _decode_json(row['payload_json'], {}), + 'remote_id': _decode_json(row['remote_id_json'], {}), + } + + +def list_drone_detections( + session_id: int | None = None, + source: str | None = None, + min_confidence: float = 0.0, + limit: int = 200, +) -> list[dict]: + """List Drone Ops detections with optional filters.""" + conditions = ['confidence >= ?'] + params: list[Any] = [min_confidence] + + if session_id is not None: + conditions.append('session_id = ?') + params.append(session_id) + if source: + conditions.append('source = ?') + params.append(source.strip().lower()) + + where_clause = 'WHERE ' + ' AND '.join(conditions) + params.append(limit) + + with get_db() as conn: + rows = conn.execute(f''' + SELECT * FROM drone_detections + {where_clause} + ORDER BY last_seen DESC + LIMIT ? + ''', params).fetchall() + + return [ + { + 'id': row['id'], + 'session_id': row['session_id'], + 'source': row['source'], + 'identifier': row['identifier'], + 'classification': row['classification'], + 'confidence': float(row['confidence'] or 0.0), + 'first_seen': row['first_seen'], + 'last_seen': row['last_seen'], + 'payload': _decode_json(row['payload_json'], {}), + 'remote_id': _decode_json(row['remote_id_json'], {}), + } + for row in rows + ] + + +def add_drone_track( + detection_id: int, + lat: float | None = None, + lon: float | None = None, + altitude_m: float | None = None, + speed_mps: float | None = None, + heading_deg: float | None = None, + quality: float | None = None, + source: str | None = None, +) -> int: + """Add a track point for a detection.""" + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO drone_tracks + (detection_id, lat, lon, altitude_m, speed_mps, heading_deg, quality, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', (detection_id, lat, lon, altitude_m, speed_mps, heading_deg, quality, source)) + return int(cursor.lastrowid) + + +def list_drone_tracks( + detection_id: int | None = None, + identifier: str | None = None, + limit: int = 1000, +) -> list[dict]: + """List track points by detection ID or detection identifier.""" + params: list[Any] = [] + where_clause = '' + join_clause = '' + + if detection_id is not None: + where_clause = 'WHERE t.detection_id = ?' + params.append(detection_id) + elif identifier: + join_clause = 'JOIN drone_detections d ON t.detection_id = d.id' + where_clause = 'WHERE d.identifier = ?' + params.append(identifier) + + params.append(limit) + + with get_db() as conn: + rows = conn.execute(f''' + SELECT t.* + FROM drone_tracks t + {join_clause} + {where_clause} + ORDER BY t.timestamp DESC + LIMIT ? + ''', params).fetchall() + + return [ + { + 'id': row['id'], + 'detection_id': row['detection_id'], + 'timestamp': row['timestamp'], + 'lat': row['lat'], + 'lon': row['lon'], + 'altitude_m': row['altitude_m'], + 'speed_mps': row['speed_mps'], + 'heading_deg': row['heading_deg'], + 'quality': row['quality'], + 'source': row['source'], + } + for row in rows + ] + + +def add_drone_correlation( + drone_identifier: str, + operator_identifier: str, + method: str, + confidence: float, + evidence: dict | None = None, +) -> int: + """Store a drone/operator correlation result.""" + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO drone_correlations + (drone_identifier, operator_identifier, method, confidence, evidence_json) + VALUES (?, ?, ?, ?, ?) + ''', ( + drone_identifier, + operator_identifier, + method, + confidence, + json.dumps(evidence) if evidence else None, + )) + return int(cursor.lastrowid) + + +def list_drone_correlations( + drone_identifier: str | None = None, + min_confidence: float = 0.0, + limit: int = 200, +) -> list[dict]: + """List drone correlation records.""" + conditions = ['confidence >= ?'] + params: list[Any] = [min_confidence] + if drone_identifier: + conditions.append('drone_identifier = ?') + params.append(drone_identifier) + params.append(limit) + + with get_db() as conn: + rows = conn.execute(f''' + SELECT * FROM drone_correlations + WHERE {" AND ".join(conditions)} + ORDER BY created_at DESC + LIMIT ? + ''', params).fetchall() + + return [ + { + 'id': row['id'], + 'drone_identifier': row['drone_identifier'], + 'operator_identifier': row['operator_identifier'], + 'method': row['method'], + 'confidence': float(row['confidence'] or 0.0), + 'evidence': _decode_json(row['evidence_json'], {}), + 'created_at': row['created_at'], + } + for row in rows + ] + + +def create_drone_incident( + title: str, + severity: str = 'medium', + opened_by: str | None = None, + summary: str | None = None, + metadata: dict | None = None, +) -> int: + """Create a Drone Ops incident.""" + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO drone_incidents + (title, severity, opened_by, summary, metadata) + VALUES (?, ?, ?, ?, ?) + ''', (title, severity, opened_by, summary, json.dumps(metadata) if metadata else None)) + return int(cursor.lastrowid) + + +def update_drone_incident( + incident_id: int, + status: str | None = None, + severity: str | None = None, + summary: str | None = None, + metadata: dict | None = None, +) -> bool: + """Update incident status/metadata.""" + updates = [] + params: list[Any] = [] + + if status is not None: + updates.append('status = ?') + params.append(status) + if status.lower() == 'closed': + updates.append('closed_at = CURRENT_TIMESTAMP') + elif status.lower() in {'open', 'active'}: + updates.append('closed_at = NULL') + if severity is not None: + updates.append('severity = ?') + params.append(severity) + if summary is not None: + updates.append('summary = ?') + params.append(summary) + if metadata is not None: + updates.append('metadata = ?') + params.append(json.dumps(metadata)) + + if not updates: + return False + + params.append(incident_id) + with get_db() as conn: + cursor = conn.execute( + f'UPDATE drone_incidents SET {", ".join(updates)} WHERE id = ?', + params, + ) + return cursor.rowcount > 0 + + +def add_drone_incident_artifact( + incident_id: int, + artifact_type: str, + artifact_ref: str, + added_by: str | None = None, + metadata: dict | None = None, +) -> int: + """Add an artifact reference to an incident.""" + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO drone_incident_artifacts + (incident_id, artifact_type, artifact_ref, added_by, metadata) + VALUES (?, ?, ?, ?, ?) + ''', ( + incident_id, + artifact_type, + artifact_ref, + added_by, + json.dumps(metadata) if metadata else None, + )) + return int(cursor.lastrowid) + + +def get_drone_incident(incident_id: int) -> dict | None: + """Get an incident with linked artifacts and manifests.""" + with get_db() as conn: + row = conn.execute( + 'SELECT * FROM drone_incidents WHERE id = ?', + (incident_id,), + ).fetchone() + if not row: + return None + + artifacts_rows = conn.execute(''' + SELECT * FROM drone_incident_artifacts + WHERE incident_id = ? + ORDER BY added_at DESC + ''', (incident_id,)).fetchall() + + manifests_rows = conn.execute(''' + SELECT id, incident_id, created_at, hash_algo, signature, created_by, manifest_json + FROM evidence_manifests + WHERE incident_id = ? + ORDER BY created_at DESC + ''', (incident_id,)).fetchall() + + return { + 'id': row['id'], + 'title': row['title'], + 'status': row['status'], + 'severity': row['severity'], + 'opened_by': row['opened_by'], + 'opened_at': row['opened_at'], + 'closed_at': row['closed_at'], + 'summary': row['summary'], + 'metadata': _decode_json(row['metadata'], {}), + 'artifacts': [ + { + 'id': a['id'], + 'incident_id': a['incident_id'], + 'artifact_type': a['artifact_type'], + 'artifact_ref': a['artifact_ref'], + 'added_by': a['added_by'], + 'added_at': a['added_at'], + 'metadata': _decode_json(a['metadata'], {}), + } + for a in artifacts_rows + ], + 'manifests': [ + { + 'id': m['id'], + 'incident_id': m['incident_id'], + 'created_at': m['created_at'], + 'hash_algo': m['hash_algo'], + 'signature': m['signature'], + 'created_by': m['created_by'], + 'manifest': _decode_json(m['manifest_json'], {}), + } + for m in manifests_rows + ], + } + + +def list_drone_incidents(status: str | None = None, limit: int = 100) -> list[dict]: + """List incidents.""" + query = 'SELECT * FROM drone_incidents' + params: list[Any] = [] + if status: + query += ' WHERE status = ?' + params.append(status) + query += ' ORDER BY opened_at DESC LIMIT ?' + params.append(limit) + + with get_db() as conn: + rows = conn.execute(query, params).fetchall() + return [ + { + 'id': row['id'], + 'title': row['title'], + 'status': row['status'], + 'severity': row['severity'], + 'opened_by': row['opened_by'], + 'opened_at': row['opened_at'], + 'closed_at': row['closed_at'], + 'summary': row['summary'], + 'metadata': _decode_json(row['metadata'], {}), + } + for row in rows + ] + + +def create_action_request( + incident_id: int, + action_type: str, + requested_by: str, + payload: dict | None = None, +) -> int: + """Create an action request record.""" + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO action_requests + (incident_id, action_type, requested_by, payload_json) + VALUES (?, ?, ?, ?) + ''', ( + incident_id, + action_type, + requested_by, + json.dumps(payload) if payload else None, + )) + return int(cursor.lastrowid) + + +def update_action_request_status( + request_id: int, + status: str, + executed_by: str | None = None, +) -> bool: + """Update action request status.""" + with get_db() as conn: + if status.lower() == 'executed': + cursor = conn.execute(''' + UPDATE action_requests + SET status = ?, executed_at = CURRENT_TIMESTAMP, executed_by = ? + WHERE id = ? + ''', (status, executed_by, request_id)) + else: + cursor = conn.execute( + 'UPDATE action_requests SET status = ? WHERE id = ?', + (status, request_id), + ) + return cursor.rowcount > 0 + + +def get_action_request(request_id: int) -> dict | None: + """Get action request with approval summary.""" + with get_db() as conn: + row = conn.execute( + 'SELECT * FROM action_requests WHERE id = ?', + (request_id,), + ).fetchone() + if not row: + return None + + approvals = conn.execute(''' + SELECT id, request_id, approved_by, approved_at, decision, notes + FROM action_approvals + WHERE request_id = ? + ORDER BY approved_at ASC + ''', (request_id,)).fetchall() + + return { + 'id': row['id'], + 'incident_id': row['incident_id'], + 'action_type': row['action_type'], + 'requested_by': row['requested_by'], + 'requested_at': row['requested_at'], + 'status': row['status'], + 'payload': _decode_json(row['payload_json'], {}), + 'executed_at': row['executed_at'], + 'executed_by': row['executed_by'], + 'approvals': [ + { + 'id': ap['id'], + 'request_id': ap['request_id'], + 'approved_by': ap['approved_by'], + 'approved_at': ap['approved_at'], + 'decision': ap['decision'], + 'notes': ap['notes'], + } + for ap in approvals + ], + } + + +def list_action_requests( + incident_id: int | None = None, + status: str | None = None, + limit: int = 100, +) -> list[dict]: + """List action requests with optional filtering.""" + conditions = [] + params: list[Any] = [] + if incident_id is not None: + conditions.append('incident_id = ?') + params.append(incident_id) + if status: + conditions.append('status = ?') + params.append(status) + where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' + params.append(limit) + + with get_db() as conn: + rows = conn.execute(f''' + SELECT * FROM action_requests + {where_clause} + ORDER BY requested_at DESC + LIMIT ? + ''', params).fetchall() + + return [ + { + 'id': row['id'], + 'incident_id': row['incident_id'], + 'action_type': row['action_type'], + 'requested_by': row['requested_by'], + 'requested_at': row['requested_at'], + 'status': row['status'], + 'payload': _decode_json(row['payload_json'], {}), + 'executed_at': row['executed_at'], + 'executed_by': row['executed_by'], + } + for row in rows + ] + + +def add_action_approval( + request_id: int, + approved_by: str, + decision: str = 'approved', + notes: str | None = None, +) -> int: + """Add an approval decision to an action request.""" + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO action_approvals + (request_id, approved_by, decision, notes) + VALUES (?, ?, ?, ?) + ''', (request_id, approved_by, decision, notes)) + return int(cursor.lastrowid) + + +def add_action_audit_log( + request_id: int | None, + event_type: str, + actor: str | None = None, + details: dict | None = None, +) -> int: + """Append an action audit log event.""" + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO action_audit_log + (request_id, event_type, actor, details_json) + VALUES (?, ?, ?, ?) + ''', ( + request_id, + event_type, + actor, + json.dumps(details) if details else None, + )) + return int(cursor.lastrowid) + + +def list_action_audit_logs( + request_id: int | None = None, + limit: int = 200, +) -> list[dict]: + """List action audit logs.""" + params: list[Any] = [] + where_clause = '' + if request_id is not None: + where_clause = 'WHERE request_id = ?' + params.append(request_id) + params.append(limit) + + with get_db() as conn: + rows = conn.execute(f''' + SELECT * FROM action_audit_log + {where_clause} + ORDER BY timestamp DESC + LIMIT ? + ''', params).fetchall() + + return [ + { + 'id': row['id'], + 'request_id': row['request_id'], + 'event_type': row['event_type'], + 'actor': row['actor'], + 'timestamp': row['timestamp'], + 'details': _decode_json(row['details_json'], {}), + } + for row in rows + ] + + +def create_evidence_manifest( + incident_id: int, + manifest: dict, + hash_algo: str = 'sha256', + signature: str | None = None, + created_by: str | None = None, +) -> int: + """Store evidence manifest metadata for an incident.""" + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO evidence_manifests + (incident_id, hash_algo, manifest_json, signature, created_by) + VALUES (?, ?, ?, ?, ?) + ''', ( + incident_id, + hash_algo, + json.dumps(manifest), + signature, + created_by, + )) + return int(cursor.lastrowid) + + +def get_evidence_manifest(manifest_id: int) -> dict | None: + """Get an evidence manifest by ID.""" + with get_db() as conn: + row = conn.execute( + 'SELECT * FROM evidence_manifests WHERE id = ?', + (manifest_id,), + ).fetchone() + if not row: + return None + return { + 'id': row['id'], + 'incident_id': row['incident_id'], + 'created_at': row['created_at'], + 'hash_algo': row['hash_algo'], + 'manifest': _decode_json(row['manifest_json'], {}), + 'signature': row['signature'], + 'created_by': row['created_by'], + } + + +def list_evidence_manifests(incident_id: int, limit: int = 50) -> list[dict]: + """List manifests for an incident.""" + with get_db() as conn: + rows = conn.execute(''' + SELECT * FROM evidence_manifests + WHERE incident_id = ? + ORDER BY created_at DESC + LIMIT ? + ''', (incident_id, limit)).fetchall() + + return [ + { + 'id': row['id'], + 'incident_id': row['incident_id'], + 'created_at': row['created_at'], + 'hash_algo': row['hash_algo'], + 'manifest': _decode_json(row['manifest_json'], {}), + 'signature': row['signature'], + 'created_by': row['created_by'], + } + for row in rows + ] diff --git a/utils/drone/__init__.py b/utils/drone/__init__.py new file mode 100644 index 0000000..ea344fb --- /dev/null +++ b/utils/drone/__init__.py @@ -0,0 +1,10 @@ +"""Drone Ops utility package.""" + +from .service import DroneOpsService, get_drone_ops_service +from .remote_id import decode_remote_id_payload + +__all__ = [ + 'DroneOpsService', + 'get_drone_ops_service', + 'decode_remote_id_payload', +] diff --git a/utils/drone/detector.py b/utils/drone/detector.py new file mode 100644 index 0000000..3886bf0 --- /dev/null +++ b/utils/drone/detector.py @@ -0,0 +1,305 @@ +"""Heuristics for identifying drone-related emissions across WiFi/BLE/RF feeds.""" + +from __future__ import annotations + +import re +from typing import Any + +from utils.drone.remote_id import decode_remote_id_payload + +SSID_PATTERNS = [ + re.compile(r'(^|[-_\s])(dji|mavic|phantom|inspire|matrice|mini)([-_\s]|$)', re.IGNORECASE), + re.compile(r'(^|[-_\s])(parrot|anafi|bebop)([-_\s]|$)', re.IGNORECASE), + re.compile(r'(^|[-_\s])(autel|evo)([-_\s]|$)', re.IGNORECASE), + re.compile(r'(^|[-_\s])(skydio|yuneec)([-_\s]|$)', re.IGNORECASE), + re.compile(r'(^|[-_\s])(uas|uav|drone|rid|opendroneid)([-_\s]|$)', re.IGNORECASE), +] + +DRONE_OUI_PREFIXES = { + '60:60:1F': 'DJI', + '90:3A:E6': 'DJI', + '34:D2:62': 'DJI', + '90:3A:AF': 'DJI', + '00:12:1C': 'Parrot', + '90:03:B7': 'Parrot', + '48:1C:B9': 'Autel', + 'AC:89:95': 'Skydio', +} + +BT_NAME_PATTERNS = [ + re.compile(r'(dji|mavic|phantom|inspire|matrice|mini)', re.IGNORECASE), + re.compile(r'(parrot|anafi|bebop)', re.IGNORECASE), + re.compile(r'(autel|evo)', re.IGNORECASE), + re.compile(r'(skydio|yuneec)', re.IGNORECASE), + re.compile(r'(remote\s?id|opendroneid|uas|uav|drone)', re.IGNORECASE), +] + +REMOTE_ID_UUID_HINTS = {'fffa', 'faff', 'fffb'} +RF_FREQ_HINTS_MHZ = (315.0, 433.92, 868.0, 915.0, 1200.0, 2400.0, 5800.0) + + +def _normalize_mac(value: Any) -> str: + text = str(value or '').strip().upper().replace('-', ':') + if len(text) >= 8: + return text + return '' + + +def _extract_wifi_event(event: dict) -> dict | None: + if not isinstance(event, dict): + return None + if isinstance(event.get('network'), dict): + return event['network'] + if event.get('type') == 'network_update' and isinstance(event.get('network'), dict): + return event['network'] + if any(k in event for k in ('bssid', 'essid', 'ssid')): + return event + return None + + +def _extract_bt_event(event: dict) -> dict | None: + if not isinstance(event, dict): + return None + if isinstance(event.get('device'), dict): + return event['device'] + if any(k in event for k in ('device_id', 'address', 'name', 'manufacturer_name', 'service_uuids')): + return event + return None + + +def _extract_frequency_mhz(event: dict) -> float | None: + if not isinstance(event, dict): + return None + + candidates = [ + event.get('frequency_mhz'), + event.get('frequency'), + ] + + if 'frequency_hz' in event: + try: + candidates.append(float(event['frequency_hz']) / 1_000_000.0) + except (TypeError, ValueError): + pass + + for value in candidates: + try: + if value is None: + continue + f = float(value) + if f > 100000: # likely in Hz + f = f / 1_000_000.0 + if 1.0 <= f <= 7000.0: + return round(f, 6) + except (TypeError, ValueError): + continue + + text = str(event.get('text') or event.get('message') or '') + match = re.search(r'([0-9]{2,4}(?:\.[0-9]+)?)\s*MHz', text, flags=re.IGNORECASE) + if match: + try: + return float(match.group(1)) + except ValueError: + return None + + return None + + +def _closest_freq_delta(freq_mhz: float) -> float: + return min(abs(freq_mhz - hint) for hint in RF_FREQ_HINTS_MHZ) + + +def _maybe_track_from_remote_id(remote_id: dict, source: str) -> dict | None: + if not remote_id.get('detected'): + return None + lat = remote_id.get('lat') + lon = remote_id.get('lon') + if lat is None or lon is None: + return None + return { + 'lat': lat, + 'lon': lon, + 'altitude_m': remote_id.get('altitude_m'), + 'speed_mps': remote_id.get('speed_mps'), + 'heading_deg': remote_id.get('heading_deg'), + 'quality': remote_id.get('confidence', 0.0), + 'source': source, + } + + +def _detect_wifi(event: dict) -> list[dict]: + network = _extract_wifi_event(event) + if not network: + return [] + + bssid = _normalize_mac(network.get('bssid') or network.get('mac') or network.get('id')) + ssid = str(network.get('essid') or network.get('ssid') or network.get('display_name') or '').strip() + identifier = bssid or ssid + if not identifier: + return [] + + score = 0.0 + reasons: list[str] = [] + + if ssid: + for pattern in SSID_PATTERNS: + if pattern.search(ssid): + score += 0.45 + reasons.append('ssid_pattern') + break + + if bssid and len(bssid) >= 8: + prefix = bssid[:8] + if prefix in DRONE_OUI_PREFIXES: + score += 0.45 + reasons.append(f'known_oui:{DRONE_OUI_PREFIXES[prefix]}') + + remote_id = decode_remote_id_payload(network) + if remote_id.get('detected'): + score = max(score, 0.75) + reasons.append('remote_id_payload') + + if score < 0.5: + return [] + + confidence = min(1.0, round(score, 3)) + classification = 'wifi_drone_remote_id' if remote_id.get('detected') else 'wifi_drone_signature' + + return [{ + 'source': 'wifi', + 'identifier': identifier, + 'classification': classification, + 'confidence': confidence, + 'payload': { + 'network': network, + 'reasons': reasons, + 'brand_hint': DRONE_OUI_PREFIXES.get(bssid[:8]) if bssid else None, + }, + 'remote_id': remote_id if remote_id.get('detected') else None, + 'track': _maybe_track_from_remote_id(remote_id, 'wifi'), + }] + + +def _detect_bluetooth(event: dict) -> list[dict]: + device = _extract_bt_event(event) + if not device: + return [] + + address = _normalize_mac(device.get('address') or device.get('mac')) + device_id = str(device.get('device_id') or '').strip() + name = str(device.get('name') or '').strip() + manufacturer = str(device.get('manufacturer_name') or '').strip() + identifier = address or device_id or name + if not identifier: + return [] + + score = 0.0 + reasons: list[str] = [] + + haystack = f'{name} {manufacturer}'.strip() + if haystack: + for pattern in BT_NAME_PATTERNS: + if pattern.search(haystack): + score += 0.55 + reasons.append('name_or_vendor_pattern') + break + + uuids = device.get('service_uuids') or [] + for uuid in uuids: + if str(uuid).replace('-', '').lower()[-4:] in REMOTE_ID_UUID_HINTS: + score = max(score, 0.7) + reasons.append('remote_id_service_uuid') + break + + tracker = device.get('tracker') if isinstance(device.get('tracker'), dict) else {} + if tracker.get('is_tracker') and 'drone' in str(tracker.get('type') or '').lower(): + score = max(score, 0.7) + reasons.append('tracker_engine_drone_label') + + remote_id = decode_remote_id_payload(device) + if remote_id.get('detected'): + score = max(score, 0.75) + reasons.append('remote_id_payload') + + if score < 0.55: + return [] + + confidence = min(1.0, round(score, 3)) + classification = 'bluetooth_drone_remote_id' if remote_id.get('detected') else 'bluetooth_drone_signature' + + return [{ + 'source': 'bluetooth', + 'identifier': identifier, + 'classification': classification, + 'confidence': confidence, + 'payload': { + 'device': device, + 'reasons': reasons, + }, + 'remote_id': remote_id if remote_id.get('detected') else None, + 'track': _maybe_track_from_remote_id(remote_id, 'bluetooth'), + }] + + +def _detect_rf(event: dict) -> list[dict]: + if not isinstance(event, dict): + return [] + + freq_mhz = _extract_frequency_mhz(event) + if freq_mhz is None: + return [] + + delta = _closest_freq_delta(freq_mhz) + if delta > 35.0: + return [] + + score = max(0.5, 0.85 - (delta / 100.0)) + confidence = min(1.0, round(score, 3)) + + event_id = str(event.get('capture_id') or event.get('id') or f'{freq_mhz:.3f}MHz') + identifier = f'rf:{event_id}' + + payload = { + 'event': event, + 'frequency_mhz': freq_mhz, + 'delta_from_known_band_mhz': round(delta, 3), + 'known_bands_mhz': list(RF_FREQ_HINTS_MHZ), + } + + return [{ + 'source': 'rf', + 'identifier': identifier, + 'classification': 'rf_drone_link_activity', + 'confidence': confidence, + 'payload': payload, + 'remote_id': None, + 'track': None, + }] + + +def detect_from_event(mode: str, event: dict, event_type: str | None = None) -> list[dict]: + """Detect drone-relevant signals from a normalized mode event.""" + mode_lower = str(mode or '').lower() + + if mode_lower.startswith('wifi'): + return _detect_wifi(event) + if mode_lower.startswith('bluetooth') or mode_lower.startswith('bt'): + return _detect_bluetooth(event) + if mode_lower in {'subghz', 'listening_scanner', 'waterfall', 'listening'}: + return _detect_rf(event) + + # Opportunistic decode from any feed that carries explicit remote ID payloads. + remote_id = decode_remote_id_payload(event) + if remote_id.get('detected'): + identifier = str(remote_id.get('uas_id') or remote_id.get('operator_id') or 'remote_id') + return [{ + 'source': mode_lower or 'unknown', + 'identifier': identifier, + 'classification': 'remote_id_detected', + 'confidence': float(remote_id.get('confidence') or 0.6), + 'payload': {'event': event, 'event_type': event_type}, + 'remote_id': remote_id, + 'track': _maybe_track_from_remote_id(remote_id, mode_lower or 'unknown'), + }] + + return [] diff --git a/utils/drone/policy.py b/utils/drone/policy.py new file mode 100644 index 0000000..b927bd0 --- /dev/null +++ b/utils/drone/policy.py @@ -0,0 +1,11 @@ +"""Drone Ops policy helpers.""" + +from __future__ import annotations + + +def required_approvals_for_action(action_type: str) -> int: + """Return required approvals for a given action type.""" + action = (action_type or '').strip().lower() + if action.startswith('passive_'): + return 1 + return 2 diff --git a/utils/drone/remote_id.py b/utils/drone/remote_id.py new file mode 100644 index 0000000..47dc604 --- /dev/null +++ b/utils/drone/remote_id.py @@ -0,0 +1,121 @@ +"""Remote ID payload normalization and lightweight decoding helpers.""" + +from __future__ import annotations + +import json +from typing import Any + + +DRONE_ID_KEYS = ('uas_id', 'drone_id', 'serial_number', 'serial', 'id', 'uasId') +OPERATOR_ID_KEYS = ('operator_id', 'pilot_id', 'operator', 'operatorId') +LAT_KEYS = ('lat', 'latitude') +LON_KEYS = ('lon', 'lng', 'longitude') +ALT_KEYS = ('alt', 'altitude', 'altitude_m', 'height') +SPEED_KEYS = ('speed', 'speed_mps', 'ground_speed') +HEADING_KEYS = ('heading', 'heading_deg', 'course') + + +def _get_nested(data: dict, *paths: str) -> Any: + for path in paths: + current: Any = data + valid = True + for part in path.split('.'): + if not isinstance(current, dict) or part not in current: + valid = False + break + current = current[part] + if valid: + return current + return None + + +def _coerce_float(value: Any) -> float | None: + try: + if value is None or value == '': + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _pick(data: dict, keys: tuple[str, ...], nested_prefixes: tuple[str, ...] = ()) -> Any: + for key in keys: + if key in data: + return data.get(key) + for prefix in nested_prefixes: + for key in keys: + hit = _get_nested(data, f'{prefix}.{key}') + if hit is not None: + return hit + return None + + +def _normalize_input(payload: Any) -> tuple[dict, str]: + if isinstance(payload, dict): + return payload, 'dict' + + if isinstance(payload, bytes): + text = payload.decode('utf-8', errors='replace').strip() + else: + text = str(payload or '').strip() + + if not text: + return {}, 'empty' + + # JSON-first parsing. + try: + parsed = json.loads(text) + if isinstance(parsed, dict): + return parsed, 'json' + except json.JSONDecodeError: + pass + + # Keep opaque string payload available to caller. + return {'raw': text}, 'raw' + + +def decode_remote_id_payload(payload: Any) -> dict: + """Decode/normalize Remote ID-like payload into a common shape.""" + data, fmt = _normalize_input(payload) + + drone_id = _pick(data, DRONE_ID_KEYS, ('remote_id', 'message', 'uas')) + operator_id = _pick(data, OPERATOR_ID_KEYS, ('remote_id', 'message', 'operator')) + + lat = _coerce_float(_pick(data, LAT_KEYS, ('remote_id', 'message', 'position'))) + lon = _coerce_float(_pick(data, LON_KEYS, ('remote_id', 'message', 'position'))) + altitude_m = _coerce_float(_pick(data, ALT_KEYS, ('remote_id', 'message', 'position'))) + speed_mps = _coerce_float(_pick(data, SPEED_KEYS, ('remote_id', 'message', 'position'))) + heading_deg = _coerce_float(_pick(data, HEADING_KEYS, ('remote_id', 'message', 'position'))) + + confidence = 0.0 + if drone_id: + confidence += 0.35 + if lat is not None and lon is not None: + confidence += 0.35 + if altitude_m is not None: + confidence += 0.15 + if operator_id: + confidence += 0.15 + confidence = min(1.0, round(confidence, 3)) + + detected = bool(drone_id or (lat is not None and lon is not None and confidence >= 0.35)) + + normalized = { + 'detected': detected, + 'source_format': fmt, + 'uas_id': str(drone_id).strip() if drone_id else None, + 'operator_id': str(operator_id).strip() if operator_id else None, + 'lat': lat, + 'lon': lon, + 'altitude_m': altitude_m, + 'speed_mps': speed_mps, + 'heading_deg': heading_deg, + 'confidence': confidence, + 'raw': data, + } + + # Remove heavy raw payload if we successfully extracted structure. + if detected and isinstance(data, dict) and len(data) > 0: + normalized['raw'] = data + + return normalized diff --git a/utils/drone/service.py b/utils/drone/service.py new file mode 100644 index 0000000..d24bff4 --- /dev/null +++ b/utils/drone/service.py @@ -0,0 +1,621 @@ +"""Stateful Drone Ops service: ingestion, policy, incidents, actions, and evidence.""" + +from __future__ import annotations + +import hashlib +import json +import queue +import threading +import time +from datetime import datetime, timezone +from typing import Any, Generator + +import app as app_module + +from utils.correlation import get_correlations as get_wifi_bt_correlations +from utils.database import ( + add_action_approval, + add_action_audit_log, + add_drone_correlation, + add_drone_incident_artifact, + add_drone_track, + create_action_request, + create_drone_incident, + create_drone_session, + create_evidence_manifest, + get_action_request, + get_active_drone_session, + get_drone_detection, + get_drone_incident, + get_drone_session, + get_evidence_manifest, + list_action_audit_logs, + list_action_requests, + list_drone_correlations, + list_drone_detections, + list_drone_incidents, + list_drone_sessions, + list_drone_tracks, + list_evidence_manifests, + stop_drone_session, + update_action_request_status, + update_drone_incident, + upsert_drone_detection, +) +from utils.drone.detector import detect_from_event +from utils.drone.remote_id import decode_remote_id_payload +from utils.trilateration import estimate_location_from_observations + + +class DroneOpsService: + """Orchestrates Drone Ops data and policy controls.""" + + def __init__(self) -> None: + self._subscribers: set[queue.Queue] = set() + self._subs_lock = threading.Lock() + + self._policy_lock = threading.Lock() + self._armed_until_ts: float | None = None + self._armed_by: str | None = None + self._arm_reason: str | None = None + self._arm_incident_id: int | None = None + + # ------------------------------------------------------------------ + # Streaming + # ------------------------------------------------------------------ + + @staticmethod + def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + def _emit(self, event_type: str, payload: dict) -> None: + envelope = { + 'type': event_type, + 'timestamp': self._utc_now_iso(), + 'payload': payload, + } + with self._subs_lock: + subscribers = tuple(self._subscribers) + + for sub in subscribers: + try: + sub.put_nowait(envelope) + except queue.Full: + try: + sub.get_nowait() + sub.put_nowait(envelope) + except (queue.Empty, queue.Full): + continue + + def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]: + """Yield Drone Ops events for SSE streaming.""" + client_queue: queue.Queue = queue.Queue(maxsize=500) + + with self._subs_lock: + self._subscribers.add(client_queue) + + try: + while True: + try: + yield client_queue.get(timeout=timeout) + except queue.Empty: + yield {'type': 'keepalive', 'timestamp': self._utc_now_iso(), 'payload': {}} + finally: + with self._subs_lock: + self._subscribers.discard(client_queue) + + # ------------------------------------------------------------------ + # Policy / arming + # ------------------------------------------------------------------ + + def _policy_state_locked(self) -> dict: + armed = self._armed_until_ts is not None and time.time() < self._armed_until_ts + if not armed: + self._armed_until_ts = None + self._armed_by = None + self._arm_reason = None + self._arm_incident_id = None + + return { + 'armed': armed, + 'armed_by': self._armed_by, + 'arm_reason': self._arm_reason, + 'arm_incident_id': self._arm_incident_id, + 'armed_until': datetime.fromtimestamp(self._armed_until_ts, tz=timezone.utc).isoformat() if self._armed_until_ts else None, + 'required_approvals_default': 2, + } + + def get_policy_state(self) -> dict: + """Get current policy and arming state.""" + with self._policy_lock: + return self._policy_state_locked() + + def arm_actions( + self, + actor: str, + reason: str, + incident_id: int, + duration_seconds: int = 900, + ) -> dict: + """Arm action plane for a bounded duration.""" + duration_seconds = max(60, min(7200, int(duration_seconds or 900))) + with self._policy_lock: + self._armed_until_ts = time.time() + duration_seconds + self._armed_by = actor + self._arm_reason = reason + self._arm_incident_id = incident_id + state = self._policy_state_locked() + + self._emit('policy_armed', {'actor': actor, 'reason': reason, 'incident_id': incident_id, 'state': state}) + return state + + def disarm_actions(self, actor: str, reason: str | None = None) -> dict: + """Disarm action plane.""" + with self._policy_lock: + self._armed_until_ts = None + self._armed_by = None + self._arm_reason = None + self._arm_incident_id = None + state = self._policy_state_locked() + + self._emit('policy_disarmed', {'actor': actor, 'reason': reason, 'state': state}) + return state + + @staticmethod + def required_approvals(action_type: str) -> int: + """Compute required approvals for an action type.""" + action = (action_type or '').strip().lower() + if action.startswith('passive_'): + return 1 + return 2 + + # ------------------------------------------------------------------ + # Sessions and detections + # ------------------------------------------------------------------ + + def start_session( + self, + mode: str, + label: str | None, + operator: str, + metadata: dict | None = None, + ) -> dict: + """Start a Drone Ops session.""" + active = get_active_drone_session() + if active: + return active + + session_id = create_drone_session( + mode=mode or 'passive', + label=label, + operator=operator, + metadata=metadata, + ) + session = get_drone_session(session_id) + if session: + self._emit('session_started', {'session': session}) + return session or {} + + def stop_session( + self, + operator: str, + session_id: int | None = None, + summary: dict | None = None, + ) -> dict | None: + """Stop a Drone Ops session.""" + active = get_active_drone_session() + target_id = session_id or (active['id'] if active else None) + if not target_id: + return None + + if summary is None: + summary = { + 'operator': operator, + 'stopped_at': self._utc_now_iso(), + 'detections': len(list_drone_detections(session_id=target_id, limit=1000)), + } + + stop_drone_session(target_id, summary=summary) + session = get_drone_session(target_id) + if session: + self._emit('session_stopped', {'session': session}) + return session + + def get_status(self) -> dict: + """Get full Drone Ops status payload.""" + return { + 'status': 'success', + 'active_session': get_active_drone_session(), + 'policy': self.get_policy_state(), + 'counts': { + 'detections': len(list_drone_detections(limit=1000)), + 'incidents_open': len(list_drone_incidents(status='open', limit=1000)), + 'actions_pending': len(list_action_requests(status='pending', limit=1000)), + }, + } + + def ingest_event(self, mode: str, event: dict, event_type: str | None = None) -> None: + """Ingest cross-mode event and produce Drone Ops detections.""" + try: + detections = detect_from_event(mode, event, event_type) + except Exception: + return + + if not detections: + return + + active = get_active_drone_session() + session_id = active['id'] if active else None + + for detection in detections: + try: + detection_id = upsert_drone_detection( + session_id=session_id, + source=detection['source'], + identifier=detection['identifier'], + classification=detection.get('classification'), + confidence=float(detection.get('confidence') or 0.0), + payload=detection.get('payload') or {}, + remote_id=detection.get('remote_id') or None, + ) + row = get_drone_detection(detection_id) + + track = detection.get('track') or {} + if row and track and track.get('lat') is not None and track.get('lon') is not None: + add_drone_track( + detection_id=row['id'], + lat=track.get('lat'), + lon=track.get('lon'), + altitude_m=track.get('altitude_m'), + speed_mps=track.get('speed_mps'), + heading_deg=track.get('heading_deg'), + quality=track.get('quality'), + source=track.get('source') or detection.get('source'), + ) + + remote_id = detection.get('remote_id') or {} + uas_id = remote_id.get('uas_id') + operator_id = remote_id.get('operator_id') + if uas_id and operator_id: + add_drone_correlation( + drone_identifier=str(uas_id), + operator_identifier=str(operator_id), + method='remote_id_binding', + confidence=float(remote_id.get('confidence') or 0.8), + evidence={ + 'source': detection.get('source'), + 'event_type': event_type, + 'detection_id': row['id'] if row else None, + }, + ) + + if row: + self._emit('detection', { + 'mode': mode, + 'event_type': event_type, + 'detection': row, + }) + except Exception: + continue + + def decode_remote_id(self, payload: Any) -> dict: + """Decode an explicit Remote ID payload.""" + decoded = decode_remote_id_payload(payload) + self._emit('remote_id_decoded', {'decoded': decoded}) + return decoded + + # ------------------------------------------------------------------ + # Queries + # ------------------------------------------------------------------ + + def get_detections( + self, + session_id: int | None = None, + source: str | None = None, + min_confidence: float = 0.0, + limit: int = 200, + ) -> list[dict]: + return list_drone_detections( + session_id=session_id, + source=source, + min_confidence=min_confidence, + limit=limit, + ) + + def get_tracks( + self, + detection_id: int | None = None, + identifier: str | None = None, + limit: int = 1000, + ) -> list[dict]: + return list_drone_tracks( + detection_id=detection_id, + identifier=identifier, + limit=limit, + ) + + def estimate_geolocation(self, observations: list[dict], environment: str = 'outdoor') -> dict | None: + """Estimate location from observations.""" + return estimate_location_from_observations(observations, environment=environment) + + def refresh_correlations(self, min_confidence: float = 0.6) -> list[dict]: + """Refresh and persist likely drone/operator correlations from WiFi<->BT pairs.""" + wifi_devices = dict(app_module.wifi_networks) + wifi_devices.update(dict(app_module.wifi_clients)) + bt_devices = dict(app_module.bt_devices) + + pairs = get_wifi_bt_correlations( + wifi_devices=wifi_devices, + bt_devices=bt_devices, + min_confidence=min_confidence, + include_historical=True, + ) + + detections = list_drone_detections(min_confidence=0.5, limit=1000) + known_ids = {d['identifier'].upper() for d in detections} + + for pair in pairs: + wifi_mac = str(pair.get('wifi_mac') or '').upper() + bt_mac = str(pair.get('bt_mac') or '').upper() + if wifi_mac in known_ids or bt_mac in known_ids: + add_drone_correlation( + drone_identifier=wifi_mac if wifi_mac in known_ids else bt_mac, + operator_identifier=bt_mac if wifi_mac in known_ids else wifi_mac, + method='wifi_bt_correlation', + confidence=float(pair.get('confidence') or 0.0), + evidence=pair, + ) + + return list_drone_correlations(min_confidence=min_confidence, limit=200) + + # ------------------------------------------------------------------ + # Incidents and artifacts + # ------------------------------------------------------------------ + + def create_incident( + self, + title: str, + severity: str, + opened_by: str, + summary: str | None, + metadata: dict | None, + ) -> dict: + incident_id = create_drone_incident( + title=title, + severity=severity, + opened_by=opened_by, + summary=summary, + metadata=metadata, + ) + incident = get_drone_incident(incident_id) or {'id': incident_id} + self._emit('incident_created', {'incident': incident}) + return incident + + def update_incident( + self, + incident_id: int, + status: str | None = None, + severity: str | None = None, + summary: str | None = None, + metadata: dict | None = None, + ) -> dict | None: + update_drone_incident( + incident_id=incident_id, + status=status, + severity=severity, + summary=summary, + metadata=metadata, + ) + incident = get_drone_incident(incident_id) + if incident: + self._emit('incident_updated', {'incident': incident}) + return incident + + def add_incident_artifact( + self, + incident_id: int, + artifact_type: str, + artifact_ref: str, + added_by: str, + metadata: dict | None = None, + ) -> dict: + artifact_id = add_drone_incident_artifact( + incident_id=incident_id, + artifact_type=artifact_type, + artifact_ref=artifact_ref, + added_by=added_by, + metadata=metadata, + ) + artifact = { + 'id': artifact_id, + 'incident_id': incident_id, + 'artifact_type': artifact_type, + 'artifact_ref': artifact_ref, + 'added_by': added_by, + 'metadata': metadata or {}, + } + self._emit('incident_artifact_added', {'artifact': artifact}) + return artifact + + # ------------------------------------------------------------------ + # Actions and approvals + # ------------------------------------------------------------------ + + def request_action( + self, + incident_id: int, + action_type: str, + requested_by: str, + payload: dict | None, + ) -> dict | None: + request_id = create_action_request( + incident_id=incident_id, + action_type=action_type, + requested_by=requested_by, + payload=payload, + ) + add_action_audit_log( + request_id=request_id, + event_type='requested', + actor=requested_by, + details={'payload': payload or {}}, + ) + req = get_action_request(request_id) + if req: + req['required_approvals'] = self.required_approvals(req['action_type']) + self._emit('action_requested', {'request': req}) + return req + + def approve_action( + self, + request_id: int, + approver: str, + decision: str = 'approved', + notes: str | None = None, + ) -> dict | None: + req = get_action_request(request_id) + if not req: + return None + + approvals = req.get('approvals', []) + if any((a.get('approved_by') or '').lower() == approver.lower() for a in approvals): + return req + + add_action_approval(request_id=request_id, approved_by=approver, decision=decision, notes=notes) + add_action_audit_log( + request_id=request_id, + event_type='approval', + actor=approver, + details={'decision': decision, 'notes': notes}, + ) + + req = get_action_request(request_id) + if not req: + return None + + approvals = req.get('approvals', []) + approved_count = len([a for a in approvals if str(a.get('decision')).lower() == 'approved']) + required = self.required_approvals(req['action_type']) + + if decision.lower() == 'rejected': + update_action_request_status(request_id, status='rejected') + elif approved_count >= required and req.get('status') not in {'executed', 'rejected'}: + update_action_request_status(request_id, status='approved') + + req = get_action_request(request_id) + if req: + req['required_approvals'] = required + req['approved_count'] = approved_count + self._emit('action_approved', {'request': req}) + return req + + def execute_action(self, request_id: int, actor: str) -> tuple[dict | None, str | None]: + """Execute an approved action request (policy-gated).""" + req = get_action_request(request_id) + if not req: + return None, 'Action request not found' + + policy = self.get_policy_state() + if not policy.get('armed'): + return None, 'Action plane is not armed' + + approvals = req.get('approvals', []) + approved_count = len([a for a in approvals if str(a.get('decision')).lower() == 'approved']) + required = self.required_approvals(req['action_type']) + + if approved_count < required: + return None, f'Insufficient approvals ({approved_count}/{required})' + + update_action_request_status(request_id, status='executed', executed_by=actor) + add_action_audit_log( + request_id=request_id, + event_type='executed', + actor=actor, + details={ + 'dispatch': 'framework', + 'note': 'Execution recorded. Attach route-specific handlers per action_type.', + }, + ) + + req = get_action_request(request_id) + if req: + req['required_approvals'] = required + req['approved_count'] = approved_count + self._emit('action_executed', {'request': req}) + return req, None + + # ------------------------------------------------------------------ + # Evidence and manifests + # ------------------------------------------------------------------ + + def generate_evidence_manifest( + self, + incident_id: int, + created_by: str, + signature: str | None = None, + ) -> dict | None: + """Build and persist an evidence manifest for an incident.""" + incident = get_drone_incident(incident_id) + if not incident: + return None + + action_requests = list_action_requests(incident_id=incident_id, limit=1000) + request_ids = [r['id'] for r in action_requests] + action_audit: list[dict] = [] + for request_id in request_ids: + action_audit.extend(list_action_audit_logs(request_id=request_id, limit=500)) + + manifest_core = { + 'generated_at': self._utc_now_iso(), + 'incident': { + 'id': incident['id'], + 'title': incident['title'], + 'status': incident['status'], + 'severity': incident['severity'], + 'opened_at': incident['opened_at'], + 'closed_at': incident['closed_at'], + }, + 'artifact_count': len(incident.get('artifacts', [])), + 'action_request_count': len(action_requests), + 'audit_event_count': len(action_audit), + 'artifacts': incident.get('artifacts', []), + 'action_requests': action_requests, + 'action_audit': action_audit, + } + + canonical = json.dumps(manifest_core, sort_keys=True, separators=(',', ':')) + sha256_hex = hashlib.sha256(canonical.encode('utf-8')).hexdigest() + + manifest = { + **manifest_core, + 'integrity': { + 'algorithm': 'sha256', + 'digest': sha256_hex, + }, + } + + manifest_id = create_evidence_manifest( + incident_id=incident_id, + manifest=manifest, + hash_algo='sha256', + signature=signature, + created_by=created_by, + ) + + stored = get_evidence_manifest(manifest_id) + if stored: + self._emit('evidence_manifest_created', {'manifest': stored}) + return stored + + +_drone_service: DroneOpsService | None = None +_drone_service_lock = threading.Lock() + + +def get_drone_ops_service() -> DroneOpsService: + """Get global Drone Ops service singleton.""" + global _drone_service + with _drone_service_lock: + if _drone_service is None: + _drone_service = DroneOpsService() + return _drone_service diff --git a/utils/event_pipeline.py b/utils/event_pipeline.py index 6877adf..73aa5fd 100644 --- a/utils/event_pipeline.py +++ b/utils/event_pipeline.py @@ -54,6 +54,13 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) - # Alert failures should never break streaming pass + try: + from utils.drone import get_drone_ops_service + get_drone_ops_service().ingest_event(mode, event, event_type) + except Exception: + # Drone ingest should never break mode streaming + pass + def _extract_device_id(event: dict) -> str | None: for field in DEVICE_ID_FIELDS: diff --git a/utils/signal_guess.py b/utils/signal_guess.py index 8a1d8f9..badd49c 100644 --- a/utils/signal_guess.py +++ b/utils/signal_guess.py @@ -343,26 +343,10 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ regions=["GLOBAL"], ), - # LoRaWAN - SignalTypeDefinition( - label="LoRaWAN / LoRa Device", - tags=["iot", "lora", "lpwan", "telemetry"], - description="LoRa long-range IoT device", - frequency_ranges=[ - (863_000_000, 870_000_000), # EU868 - (902_000_000, 928_000_000), # US915 - ], - modulation_hints=["LoRa", "CSS", "FSK"], - bandwidth_range=(125_000, 500_000), # LoRa spreading bandwidths - base_score=11, - is_burst_type=True, - regions=["UK/EU", "US"], - ), - - # Key Fob / Remote - SignalTypeDefinition( - label="Remote Control / Key Fob", - tags=["remote", "keyfob", "automotive", "burst", "ism"], + # Key Fob / Remote + SignalTypeDefinition( + label="Remote Control / Key Fob", + tags=["remote", "keyfob", "automotive", "burst", "ism"], description="Wireless remote control or vehicle key fob", frequency_ranges=[ (314_900_000, 315_100_000), # 315 MHz (US) From af5b17e8413cf2180fcb21da78331af5b0c3b713 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 20 Feb 2026 17:09:17 +0000 Subject: [PATCH 02/14] Remove Drone Ops feature end-to-end --- docs/PROFESSIONAL_OPS_MVP.md | 192 ---- routes/__init__.py | 2 - routes/drone_ops.py | 431 ------- static/css/modes/droneops.css | 498 --------- static/js/modes/droneops.js | 1418 ------------------------ templates/index.html | 95 +- templates/partials/modes/droneops.html | 152 --- templates/partials/nav.html | 2 - tests/test_drone_ops_policy.py | 36 - tests/test_drone_ops_remote_id.py | 60 - tests/test_drone_ops_routes.py | 228 ---- utils/authz.py | 74 -- utils/database.py | 969 ---------------- utils/drone/__init__.py | 10 - utils/drone/detector.py | 305 ----- utils/drone/policy.py | 11 - utils/drone/remote_id.py | 121 -- utils/drone/service.py | 621 ----------- utils/event_pipeline.py | 8 - 19 files changed, 2 insertions(+), 5231 deletions(-) delete mode 100644 docs/PROFESSIONAL_OPS_MVP.md delete mode 100644 routes/drone_ops.py delete mode 100644 static/css/modes/droneops.css delete mode 100644 static/js/modes/droneops.js delete mode 100644 templates/partials/modes/droneops.html delete mode 100644 tests/test_drone_ops_policy.py delete mode 100644 tests/test_drone_ops_remote_id.py delete mode 100644 tests/test_drone_ops_routes.py delete mode 100644 utils/authz.py delete mode 100644 utils/drone/__init__.py delete mode 100644 utils/drone/detector.py delete mode 100644 utils/drone/policy.py delete mode 100644 utils/drone/remote_id.py delete mode 100644 utils/drone/service.py diff --git a/docs/PROFESSIONAL_OPS_MVP.md b/docs/PROFESSIONAL_OPS_MVP.md deleted file mode 100644 index f687805..0000000 --- a/docs/PROFESSIONAL_OPS_MVP.md +++ /dev/null @@ -1,192 +0,0 @@ -# Professional Ops + Drone Capability Matrix (MVP) - -This plan enables full professional capability (passive, active testing, and evidence workflows) while keeping strict authorization, approvals, and auditability. - -## 1) Capability Matrix (Feature Availability) - -| Capability | Passive (Observe) | Active (Controlled Test) | Evidence / Audit | Reuse in Current Codebase | MVP Build Additions | -|---|---|---|---|---|---| -| Drone detection and classification | Detect likely drone entities from WiFi/BLE/RF metadata | Trigger controlled test sessions to validate detector quality | Store detections with source, confidence, and timestamps | `/wifi/v2/*`, `/api/bluetooth/*`, `/subghz/*`, `utils/tscm/detector.py` | New detector adapters in `utils/drone/` and aggregation API | -| Remote ID intelligence | Parse and display drone/operator identifiers and telemetry | Run controlled replay/simulation inputs for validation | Persist decoded records and parser provenance | `routes/wifi_v2.py`, `routes/bluetooth_v2.py`, `utils/event_pipeline.py` | `utils/drone/remote_id.py`, `/drone-ops/remote-id/*` endpoints | -| C2 and video link analysis | Identify likely C2/video channels and protocol patterns | Controlled injection/exercise mode for authorized ranges | Save link assessments and confidence history | `routes/listening_post.py`, `routes/subghz.py`, `static/js/components/signal-guess.js` | `utils/drone/link_analysis.py`, `/drone-ops/links/*` | -| Multi-agent geolocation | Estimate emitter/drone position from distributed observations | Active test mode to validate location solver error bounds | Capture estimate history with confidence ellipse | `routes/controller.py` location endpoints | `/drone-ops/geolocate/*` wrapper over controller location APIs | -| Operator/drone correlation | Correlate drone, operator, and nearby device candidates | Active proximity probes in controlled tests | Store correlation graph and confidence deltas | `/correlation`, `/analytics/target`, TSCM identity clustering | `utils/drone/correlation.py`, `/drone-ops/correlations/*` | -| Geofence and rules | Alert on zone breach and route/altitude anomalies | Zone-based active scenario testing | Immutable breach timeline and alert acknowledgements | `utils/geofence.py`, `/analytics/geofences`, `/alerts/*` | Drone-specific alert templates and rule presets | -| Incident command workflow | Build incident timeline from detections/alerts/tracks | Execute approved active tasks per playbook | Case package with linked artifacts and operator notes | TSCM cases and notes in `routes/tscm.py`, `utils/database.py` | Drone case types + incident board UI in Drone Ops mode | -| Replay and reporting | In-app replay of full incident event stream | Replay active test sessions for after-action review | Export signed package (JSONL + summary + hashes) | `/recordings/*`, Analytics replay UI, TSCM report generation | Evidence manifest + integrity hashing + chain-of-custody log | -| Active action controls | N/A | Full active actions available when armed/approved | Every action requires explicit reason and audit record | Existing active surfaces in `/wifi/deauth`, `/subghz/transmit` | Approval workflow (`two-person`) + command gate middleware | -| Access control and approvals | Role-based read access | Role + arming + approval enforced per action class | Full action audit trail with actor, approver, and case ID | `users.role` and session role in `app.py` | `utils/authz.py`, approval/audit tables, route decorators | - -## 2) Architecture Mapping to Existing Routes/UI - -## Backend routes to reuse directly - -- Detection feeds: - - `routes/wifi_v2.py` (`/wifi/v2/stream`, `/wifi/v2/networks`, `/wifi/v2/clients`, `/wifi/v2/probes`) - - `routes/bluetooth_v2.py` (`/api/bluetooth/stream`, `/api/bluetooth/devices`) - - `routes/subghz.py` (`/subghz/stream`, receive/decode status) -- Correlation and analytics: - - `routes/correlation.py` - - `routes/analytics.py` (`/analytics/target`, `/analytics/patterns`, `/analytics/geofences`) -- Multi-agent geolocation: - - `routes/controller.py` (`/controller/api/location/estimate`, `/controller/api/location/observe`, `/controller/api/location/all`) -- Alerts and recording: - - `routes/alerts.py` and `utils/alerts.py` - - `routes/recordings.py` and `utils/recording.py` - - Shared pipeline in `utils/event_pipeline.py` -- Case and reporting substrate: - - `routes/tscm.py` (`/tscm/cases`, `/tscm/report/pdf`, playbooks) - - TSCM persistence in `utils/database.py` - -## New backend module for MVP - -- Add `routes/drone_ops.py` with URL prefix `/drone-ops`. -- Add `utils/drone/` package: - - `aggregator.py` (normalize events from WiFi/BLE/RF) - - `remote_id.py` (parsers + confidence attribution) - - `link_analysis.py` (C2/video heuristics) - - `geo.py` (adapter to controller location estimation) - - `policy.py` (arming, approval, role checks) - -## Frontend integration points - -- Navigation: - - `templates/partials/nav.html` add `droneops` entry (Intel group). -- Mode panel: - - `templates/partials/modes/droneops.html` - - `static/js/modes/droneops.js` - - `static/css/modes/droneops.css` -- Main mode loader wiring: - - `templates/index.html` for new panel include + script/css registration. -- Cross-mode widgets: - - Reuse `static/js/components/signal-cards.js`, `timeline-heatmap.js`, `activity-timeline.js`. - -## 3) Proposed MVP API Surface (`/drone-ops`) - -| Endpoint | Method | Purpose | -|---|---|---| -| `/drone-ops/status` | `GET` | Health, active session, arming state, policy snapshot | -| `/drone-ops/session/start` | `POST` | Start passive/active Drone Ops session with metadata | -| `/drone-ops/session/stop` | `POST` | Stop session and finalize summary | -| `/drone-ops/detections` | `GET` | Current detection list with filters | -| `/drone-ops/stream` | `GET` (SSE) | Unified live stream (detections, tracks, alerts, approvals) | -| `/drone-ops/remote-id/decode` | `POST` | Decode submitted frame payload (test and replay support) | -| `/drone-ops/tracks` | `GET` | Track list and selected track history | -| `/drone-ops/geolocate/estimate` | `POST` | Request geolocation estimate from distributed observations | -| `/drone-ops/correlations` | `GET` | Drone/operator/device correlation graph | -| `/drone-ops/incidents` | `POST` / `GET` | Create/list incidents | -| `/drone-ops/incidents/` | `GET` / `PUT` | Incident detail and status updates | -| `/drone-ops/incidents//artifacts` | `POST` | Attach notes, detections, tracks, alerts, recordings | -| `/drone-ops/actions/arm` | `POST` | Arm active action plane with reason + case/incident ID | -| `/drone-ops/actions/request` | `POST` | Submit active action requiring approval policy | -| `/drone-ops/actions/approve/` | `POST` | Secondary approval (if required) | -| `/drone-ops/actions/execute/` | `POST` | Execute approved action via gated dispatcher | - -## 4) Data Model Additions (SQLite, MVP) - -Add to `utils/database.py`: - -- `drone_sessions` - - `id`, `started_at`, `stopped_at`, `mode` (`passive`/`active`), `operator`, `metadata` -- `drone_detections` - - `id`, `session_id`, `first_seen`, `last_seen`, `source`, `identifier`, `confidence`, `payload_json` -- `drone_tracks` - - `id`, `detection_id`, `timestamp`, `lat`, `lon`, `altitude_m`, `speed_mps`, `heading_deg`, `quality` -- `drone_correlations` - - `id`, `drone_identifier`, `operator_identifier`, `method`, `confidence`, `evidence_json`, `created_at` -- `drone_incidents` - - `id`, `title`, `status`, `severity`, `opened_by`, `opened_at`, `closed_at`, `summary` -- `drone_incident_artifacts` - - `id`, `incident_id`, `artifact_type`, `artifact_ref`, `added_by`, `added_at`, `metadata` -- `action_requests` - - `id`, `incident_id`, `action_type`, `requested_by`, `requested_at`, `status`, `payload_json` -- `action_approvals` - - `id`, `request_id`, `approved_by`, `approved_at`, `decision`, `notes` -- `action_audit_log` - - `id`, `request_id`, `event_type`, `actor`, `timestamp`, `details_json` -- `evidence_manifests` - - `id`, `incident_id`, `created_at`, `hash_algo`, `manifest_json`, `signature` - -Note: existing `recording_sessions` and `alert_events` remain the primary event substrate; drone tables link to those records for case assembly. - -## 5) Authorization and Arming Model (All Features Available) - -All features remain implemented and reachable in code. Execution path depends on policy state. - -- Roles (extend `users.role` semantics): - - `viewer`: read-only - - `analyst`: passive + evidence operations - - `operator`: passive + active request submission - - `supervisor`: approval authority - - `admin`: full control + policy management -- Active command state machine: - - `DISARMED` (default): active commands denied - - `ARMED` (time-bound): request creation allowed with incident ID and reason - - `APPROVED`: dual-approval actions executable - - `EXECUTED`: immutable audit records written -- Enforcement: - - Add decorators in `utils/authz.py`: - - `@require_role(...)` - - `@require_armed` - - `@require_two_person_approval` - -## 6) MVP Delivery Plan (6 Weeks) - -## Phase 0 (Week 1): Scaffolding - -- Add `routes/drone_ops.py` blueprint and register in `routes/__init__.py`. -- Add `utils/drone/` package with aggregator skeleton. -- Add Drone Ops UI placeholders (`droneops.html`, `droneops.js`, `droneops.css`) and nav wiring. -- Add DB migration/create statements for drone tables. - -Exit criteria: -- Drone Ops mode loads, API health endpoint returns, empty-state UI renders. - -## Phase 1 (Weeks 1-2): Passive Drone Ops - -- Unified ingest from WiFi/BLE/SubGHz streams. -- Detection cards, live timeline, map tracks, geofence alert hooks. -- Remote ID decode endpoint and parser confidence model. -- Alert rule presets for drone intrusions. - -Exit criteria: -- Passive session can detect/classify, map updates in real time, alerts generated. - -## Phase 2 (Weeks 3-4): Correlation + Geolocation + Incident Workflow - -- Correlation graph (drone/operator/nearby device candidates). -- Multi-agent geolocation adapter using controller location endpoints. -- Incident creation and artifact linking. -- Replay integration using existing recordings/events APIs. - -Exit criteria: -- Operator can open incident, attach artifacts, replay key timeline, export preliminary report. - -## Phase 3 (Weeks 5-6): Active Actions + Evidence Integrity - -- Arming and approval workflows (`action_requests`, `action_approvals`). -- Active action dispatcher with role/policy checks. -- Evidence manifest export with hashes and chain-of-custody entries. -- Audit dashboards for who requested/approved/executed. - -Exit criteria: -- Active commands require approvals, all operations are auditable and exportable. - -## 7) Immediate Build Backlog (First Sprint) - -1. Create `routes/drone_ops.py` with `status`, `session/start`, `session/stop`, `stream`. -2. Add drone tables in `utils/database.py` and lightweight DAO helpers. -3. Add mode shell UI files and wire mode into `templates/index.html` and `templates/partials/nav.html`. -4. Implement aggregator wiring to existing WiFi/BT/SubGHz feeds via `utils/event_pipeline.py`. -5. Add `actions/arm` endpoint with role + incident requirement and TTL-based disarm. -6. Add baseline tests: - - `tests/test_drone_ops_routes.py` - - `tests/test_drone_ops_policy.py` - - `tests/test_drone_ops_remote_id.py` - -## 8) Risk Controls - -- False attribution risk: every correlation/geolocation output carries confidence and evidence provenance. -- Policy bypass risk: active command execution path only through centralized dispatcher. -- Evidence integrity risk: hash all exported artifacts and include manifest references. -- Operational safety risk: require explicit incident linkage and arming expiration. diff --git a/routes/__init__.py b/routes/__init__.py index e294894..844a117 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -36,7 +36,6 @@ def register_blueprints(app): from .bt_locate import bt_locate_bp from .analytics import analytics_bp from .space_weather import space_weather_bp - from .drone_ops import drone_ops_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -72,7 +71,6 @@ def register_blueprints(app): app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard app.register_blueprint(space_weather_bp) # Space weather monitoring - app.register_blueprint(drone_ops_bp) # Drone Ops / professional workflow # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/drone_ops.py b/routes/drone_ops.py deleted file mode 100644 index 23de0d3..0000000 --- a/routes/drone_ops.py +++ /dev/null @@ -1,431 +0,0 @@ -"""Drone Ops routes: professional workflow for detection, incidents, actions, and evidence.""" - -from __future__ import annotations - -from flask import Blueprint, Response, jsonify, request - -from utils.authz import current_username, require_armed, require_role -from utils.database import ( - get_action_request, - get_drone_incident, - get_evidence_manifest, - list_action_audit_logs, - list_action_requests, - list_drone_correlations, - list_drone_sessions, - list_drone_incidents, - list_evidence_manifests, -) -from utils.drone import get_drone_ops_service -from utils.sse import format_sse - - -drone_ops_bp = Blueprint('drone_ops', __name__, url_prefix='/drone-ops') - - -def _json_body() -> dict: - return request.get_json(silent=True) or {} - - -@drone_ops_bp.route('/status', methods=['GET']) -@require_role('viewer') -def status() -> Response: - service = get_drone_ops_service() - return jsonify(service.get_status()) - - -@drone_ops_bp.route('/sessions', methods=['GET']) -@require_role('viewer') -def list_sessions() -> Response: - limit = max(1, min(500, request.args.get('limit', default=50, type=int))) - active_only = request.args.get('active_only', 'false').lower() == 'true' - return jsonify({ - 'status': 'success', - 'sessions': list_drone_sessions(limit=limit, active_only=active_only), - }) - - -@drone_ops_bp.route('/session/start', methods=['POST']) -@require_role('operator') -def start_session() -> Response: - data = _json_body() - mode = str(data.get('mode') or 'passive').strip().lower() - if mode not in {'passive', 'active'}: - return jsonify({'status': 'error', 'message': 'mode must be passive or active'}), 400 - - label = data.get('label') - metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} - - service = get_drone_ops_service() - session = service.start_session( - mode=mode, - label=label, - operator=current_username(), - metadata=metadata, - ) - return jsonify({'status': 'success', 'session': session}) - - -@drone_ops_bp.route('/session/stop', methods=['POST']) -@require_role('operator') -def stop_session() -> Response: - data = _json_body() - session_id = data.get('id') - try: - session_id_int = int(session_id) if session_id is not None else None - except (TypeError, ValueError): - return jsonify({'status': 'error', 'message': 'id must be an integer'}), 400 - - summary = data.get('summary') if isinstance(data.get('summary'), dict) else None - service = get_drone_ops_service() - session = service.stop_session( - operator=current_username(), - session_id=session_id_int, - summary=summary, - ) - if not session: - return jsonify({'status': 'error', 'message': 'No active session found'}), 404 - return jsonify({'status': 'success', 'session': session}) - - -@drone_ops_bp.route('/detections', methods=['GET']) -@require_role('viewer') -def detections() -> Response: - service = get_drone_ops_service() - source = request.args.get('source') - min_confidence = request.args.get('min_confidence', default=0.0, type=float) - limit = max(1, min(5000, request.args.get('limit', default=200, type=int))) - session_id = request.args.get('session_id', default=None, type=int) - - rows = service.get_detections( - session_id=session_id, - source=source, - min_confidence=min_confidence, - limit=limit, - ) - return jsonify({'status': 'success', 'count': len(rows), 'detections': rows}) - - -@drone_ops_bp.route('/stream', methods=['GET']) -@require_role('viewer') -def stream() -> Response: - service = get_drone_ops_service() - - def _generate(): - for event in service.stream_events(timeout=1.0): - evt_name = event.get('type') if isinstance(event, dict) else None - payload = event - yield format_sse(payload, event=evt_name) - - response = Response(_generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['Connection'] = 'keep-alive' - response.headers['X-Accel-Buffering'] = 'no' - return response - - -@drone_ops_bp.route('/remote-id/decode', methods=['POST']) -@require_role('analyst') -def decode_remote_id() -> Response: - data = _json_body() - payload = data.get('payload') - if payload is None: - return jsonify({'status': 'error', 'message': 'payload is required'}), 400 - - service = get_drone_ops_service() - decoded = service.decode_remote_id(payload) - return jsonify({'status': 'success', 'decoded': decoded}) - - -@drone_ops_bp.route('/tracks', methods=['GET']) -@require_role('viewer') -def tracks() -> Response: - service = get_drone_ops_service() - detection_id = request.args.get('detection_id', default=None, type=int) - identifier = request.args.get('identifier') - limit = max(1, min(5000, request.args.get('limit', default=1000, type=int))) - - rows = service.get_tracks(detection_id=detection_id, identifier=identifier, limit=limit) - return jsonify({'status': 'success', 'count': len(rows), 'tracks': rows}) - - -@drone_ops_bp.route('/geolocate/estimate', methods=['POST']) -@require_role('analyst') -def geolocate_estimate() -> Response: - data = _json_body() - observations = data.get('observations') - environment = str(data.get('environment') or 'outdoor') - - if not isinstance(observations, list) or len(observations) < 3: - return jsonify({'status': 'error', 'message': 'at least 3 observations are required'}), 400 - - service = get_drone_ops_service() - try: - location = service.estimate_geolocation(observations=observations, environment=environment) - except Exception as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 - - return jsonify({'status': 'success', 'location': location}) - - -@drone_ops_bp.route('/correlations', methods=['GET']) -@require_role('analyst') -def correlations() -> Response: - min_confidence = request.args.get('min_confidence', default=0.6, type=float) - refresh = request.args.get('refresh', 'true').lower() == 'true' - service = get_drone_ops_service() - - if refresh: - rows = service.refresh_correlations(min_confidence=min_confidence) - else: - rows = list_drone_correlations(min_confidence=min_confidence, limit=200) - - return jsonify({'status': 'success', 'count': len(rows), 'correlations': rows}) - - -@drone_ops_bp.route('/incidents', methods=['GET']) -@require_role('viewer') -def incidents_list() -> Response: - status = request.args.get('status') - limit = max(1, min(1000, request.args.get('limit', default=100, type=int))) - rows = list_drone_incidents(status=status, limit=limit) - return jsonify({'status': 'success', 'count': len(rows), 'incidents': rows}) - - -@drone_ops_bp.route('/incidents', methods=['POST']) -@require_role('operator') -def incidents_create() -> Response: - data = _json_body() - title = str(data.get('title') or '').strip() - if not title: - return jsonify({'status': 'error', 'message': 'title is required'}), 400 - - severity = str(data.get('severity') or 'medium').strip().lower() - summary = data.get('summary') - metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} - - service = get_drone_ops_service() - incident = service.create_incident( - title=title, - severity=severity, - opened_by=current_username(), - summary=summary, - metadata=metadata, - ) - return jsonify({'status': 'success', 'incident': incident}), 201 - - -@drone_ops_bp.route('/incidents/', methods=['GET']) -@require_role('viewer') -def incidents_get(incident_id: int) -> Response: - incident = get_drone_incident(incident_id) - if not incident: - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - return jsonify({'status': 'success', 'incident': incident}) - - -@drone_ops_bp.route('/incidents/', methods=['PUT']) -@require_role('operator') -def incidents_update(incident_id: int) -> Response: - data = _json_body() - service = get_drone_ops_service() - incident = service.update_incident( - incident_id=incident_id, - status=data.get('status'), - severity=data.get('severity'), - summary=data.get('summary'), - metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else None, - ) - if not incident: - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - return jsonify({'status': 'success', 'incident': incident}) - - -@drone_ops_bp.route('/incidents//artifacts', methods=['POST']) -@require_role('operator') -def incidents_add_artifact(incident_id: int) -> Response: - if not get_drone_incident(incident_id): - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - - data = _json_body() - artifact_type = str(data.get('artifact_type') or '').strip() - artifact_ref = str(data.get('artifact_ref') or '').strip() - metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} - - if not artifact_type or not artifact_ref: - return jsonify({'status': 'error', 'message': 'artifact_type and artifact_ref are required'}), 400 - - service = get_drone_ops_service() - artifact = service.add_incident_artifact( - incident_id=incident_id, - artifact_type=artifact_type, - artifact_ref=artifact_ref, - added_by=current_username(), - metadata=metadata, - ) - return jsonify({'status': 'success', 'artifact': artifact}), 201 - - -@drone_ops_bp.route('/actions/arm', methods=['POST']) -@require_role('operator') -def actions_arm() -> Response: - data = _json_body() - reason = str(data.get('reason') or '').strip() - incident_id = data.get('incident_id') - duration_seconds = data.get('duration_seconds', 900) - - if not reason: - return jsonify({'status': 'error', 'message': 'reason is required'}), 400 - try: - incident_id_int = int(incident_id) - except (TypeError, ValueError): - return jsonify({'status': 'error', 'message': 'incident_id is required and must be an integer'}), 400 - - if not get_drone_incident(incident_id_int): - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - - service = get_drone_ops_service() - state = service.arm_actions( - actor=current_username(), - reason=reason, - incident_id=incident_id_int, - duration_seconds=duration_seconds, - ) - return jsonify({'status': 'success', 'policy': state}) - - -@drone_ops_bp.route('/actions/disarm', methods=['POST']) -@require_role('operator') -def actions_disarm() -> Response: - data = _json_body() - reason = str(data.get('reason') or '').strip() or None - service = get_drone_ops_service() - state = service.disarm_actions(actor=current_username(), reason=reason) - return jsonify({'status': 'success', 'policy': state}) - - -@drone_ops_bp.route('/actions/request', methods=['POST']) -@require_role('operator') -def actions_request() -> Response: - data = _json_body() - try: - incident_id = int(data.get('incident_id')) - except (TypeError, ValueError): - return jsonify({'status': 'error', 'message': 'incident_id is required'}), 400 - - if not get_drone_incident(incident_id): - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - - action_type = str(data.get('action_type') or '').strip() - if not action_type: - return jsonify({'status': 'error', 'message': 'action_type is required'}), 400 - - payload = data.get('payload') if isinstance(data.get('payload'), dict) else {} - - service = get_drone_ops_service() - action_request = service.request_action( - incident_id=incident_id, - action_type=action_type, - requested_by=current_username(), - payload=payload, - ) - return jsonify({'status': 'success', 'request': action_request}), 201 - - -@drone_ops_bp.route('/actions/approve/', methods=['POST']) -@require_role('supervisor') -def actions_approve(request_id: int) -> Response: - data = _json_body() - decision = str(data.get('decision') or 'approved').strip().lower() - notes = data.get('notes') - - if decision not in {'approved', 'rejected'}: - return jsonify({'status': 'error', 'message': 'decision must be approved or rejected'}), 400 - - service = get_drone_ops_service() - req = service.approve_action( - request_id=request_id, - approver=current_username(), - decision=decision, - notes=notes, - ) - if not req: - return jsonify({'status': 'error', 'message': 'Action request not found'}), 404 - return jsonify({'status': 'success', 'request': req}) - - -@drone_ops_bp.route('/actions/execute/', methods=['POST']) -@require_role('operator') -@require_armed -def actions_execute(request_id: int) -> Response: - service = get_drone_ops_service() - req, error = service.execute_action(request_id=request_id, actor=current_username()) - if error: - return jsonify({'status': 'error', 'message': error}), 400 - return jsonify({'status': 'success', 'request': req}) - - -@drone_ops_bp.route('/actions/requests', methods=['GET']) -@require_role('viewer') -def actions_list() -> Response: - incident_id = request.args.get('incident_id', default=None, type=int) - status = request.args.get('status') - limit = max(1, min(1000, request.args.get('limit', default=100, type=int))) - - rows = list_action_requests(incident_id=incident_id, status=status, limit=limit) - return jsonify({'status': 'success', 'count': len(rows), 'requests': rows}) - - -@drone_ops_bp.route('/actions/requests/', methods=['GET']) -@require_role('viewer') -def actions_get(request_id: int) -> Response: - row = get_action_request(request_id) - if not row: - return jsonify({'status': 'error', 'message': 'Action request not found'}), 404 - return jsonify({'status': 'success', 'request': row}) - - -@drone_ops_bp.route('/actions/audit', methods=['GET']) -@require_role('viewer') -def actions_audit() -> Response: - request_id = request.args.get('request_id', default=None, type=int) - limit = max(1, min(2000, request.args.get('limit', default=200, type=int))) - rows = list_action_audit_logs(request_id=request_id, limit=limit) - return jsonify({'status': 'success', 'count': len(rows), 'events': rows}) - - -@drone_ops_bp.route('/evidence//manifest', methods=['POST']) -@require_role('analyst') -def evidence_manifest_create(incident_id: int) -> Response: - if not get_drone_incident(incident_id): - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - - data = _json_body() - signature = data.get('signature') - - service = get_drone_ops_service() - manifest = service.generate_evidence_manifest( - incident_id=incident_id, - created_by=current_username(), - signature=signature, - ) - if not manifest: - return jsonify({'status': 'error', 'message': 'Failed to generate manifest'}), 500 - return jsonify({'status': 'success', 'manifest': manifest}), 201 - - -@drone_ops_bp.route('/evidence/manifests/', methods=['GET']) -@require_role('viewer') -def evidence_manifest_get(manifest_id: int) -> Response: - row = get_evidence_manifest(manifest_id) - if not row: - return jsonify({'status': 'error', 'message': 'Manifest not found'}), 404 - return jsonify({'status': 'success', 'manifest': row}) - - -@drone_ops_bp.route('/evidence//manifests', methods=['GET']) -@require_role('viewer') -def evidence_manifest_list(incident_id: int) -> Response: - limit = max(1, min(500, request.args.get('limit', default=50, type=int))) - rows = list_evidence_manifests(incident_id=incident_id, limit=limit) - return jsonify({'status': 'success', 'count': len(rows), 'manifests': rows}) diff --git a/static/css/modes/droneops.css b/static/css/modes/droneops.css deleted file mode 100644 index 573e360..0000000 --- a/static/css/modes/droneops.css +++ /dev/null @@ -1,498 +0,0 @@ -/* Drone Ops mode styling */ - -#droneOpsMode { - display: none; -} - -#droneOpsMode.active { - display: block; -} - -.droneops-status-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-bottom: 10px; -} - -.droneops-status-card { - border: 1px solid var(--border-color); - background: var(--bg-secondary); - border-radius: 6px; - padding: 8px; -} - -.droneops-status-label { - font-size: 10px; - color: var(--text-secondary); - margin-bottom: 4px; - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.droneops-status-value { - font-size: 13px; - font-weight: 700; - color: var(--text-primary); -} - -.droneops-row { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-bottom: 8px; - align-items: stretch; -} - -.droneops-row > * { - min-width: 0; -} - -.droneops-row--actions { - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); -} - -.droneops-source-block { - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--bg-secondary); - padding: 8px; - margin-bottom: 8px; -} - -.droneops-source-title { - font-size: 10px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.08em; - margin-bottom: 6px; -} - -.droneops-row--sources { - margin-bottom: 0; -} - -.droneops-field { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.droneops-field-label { - font-size: 10px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.droneops-source-refresh { - grid-column: 1 / -1; -} - -.droneops-input { - width: 100%; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - color: var(--text-primary); - border-radius: 5px; - padding: 6px 8px; - font-size: 11px; -} - -#droneOpsMode .preset-btn, -#droneOpsMode .clear-btn { - width: 100%; - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: center; - text-align: center; - line-height: 1.2; - white-space: normal !important; - overflow-wrap: anywhere; - word-break: break-word; - letter-spacing: 0.05em; - font-size: 11px; - padding: 8px 10px; -} - -.droneops-list { - max-height: 260px; - overflow-y: auto; - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--bg-secondary); -} - -.droneops-item { - border-bottom: 1px solid var(--border-color); - padding: 8px; - font-size: 11px; -} - -.droneops-item:last-child { - border-bottom: none; -} - -.droneops-item-title { - font-weight: 700; - color: var(--text-primary); -} - -.droneops-item-meta { - margin-top: 3px; - color: var(--text-secondary); - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -.droneops-item-actions { - margin-top: 6px; - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.droneops-check { - width: 100%; - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: flex-start; - gap: 6px; - font-size: 11px; - color: var(--text-primary); - border: 1px solid var(--border-color); - background: var(--bg-tertiary); - border-radius: 5px; - padding: 6px 8px; -} - -body[data-mode="droneops"] #muteBtn, -body[data-mode="droneops"] #autoScrollBtn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -body[data-mode="droneops"] #muteBtn .icon, -body[data-mode="droneops"] #autoScrollBtn .icon { - line-height: 0; -} - -.droneops-check input { - margin: 0; -} - -.droneops-subtext { - margin: 4px 0 10px; - font-size: 11px; - color: var(--text-secondary); -} - -.droneops-pill { - display: inline-flex; - align-items: center; - border-radius: 999px; - padding: 2px 8px; - font-size: 10px; - border: 1px solid var(--border-color); - color: var(--text-secondary); -} - -.droneops-pill.ok { - color: var(--accent-green); - border-color: var(--accent-green); -} - -.droneops-pill.warn { - color: var(--accent-orange); - border-color: var(--accent-orange); -} - -.droneops-pill.bad { - color: var(--accent-red); - border-color: var(--accent-red); -} - -.droneops-empty { - color: var(--text-secondary); - padding: 10px; - font-size: 11px; -} - -.droneops-main-pane { - display: none; - flex: 1; - min-height: 0; - padding: 10px; - gap: 10px; - flex-direction: column; -} - -.droneops-main-top { - display: grid; - grid-template-columns: minmax(0, 1.8fr) minmax(300px, 1fr); - gap: 10px; - min-height: 0; - flex: 1; -} - -.droneops-main-bottom { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - min-height: 260px; - flex: 1; -} - -.droneops-main-side { - display: grid; - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); - gap: 10px; - min-height: 0; -} - -.droneops-main-map-card, -.droneops-main-card { - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; -} - -.droneops-main-card-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 10px 12px; - border-bottom: 1px solid var(--border-color); - background: var(--bg-tertiary); -} - -.droneops-main-card-header h4 { - margin: 0; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-primary); -} - -.droneops-main-card-meta { - font-size: 10px; - color: var(--text-secondary); - font-family: var(--font-mono); -} - -.droneops-main-kpis { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - padding: 10px; -} - -.droneops-main-kpi { - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--bg-primary); - padding: 8px; - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.droneops-main-kpi span { - font-size: 10px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.droneops-main-kpi strong { - font-size: 15px; - color: var(--accent-cyan); - line-height: 1; - overflow-wrap: anywhere; -} - -.droneops-main-map { - flex: 1; - min-height: 320px; -} - -#droneOpsMap { - width: 100%; - height: 100%; - min-height: 320px; - background: var(--bg-primary); -} - -.droneops-main-list { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 8px; - display: flex; - flex-direction: column; - gap: 8px; -} - -.droneops-main-list.compact { - gap: 6px; -} - -.droneops-main-item { - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 8px; - background: var(--bg-primary); -} - -.droneops-main-item-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.droneops-main-item-id { - color: var(--text-primary); - font-weight: 700; - font-size: 12px; - overflow-wrap: anywhere; -} - -.droneops-main-item-meta { - margin-top: 4px; - color: var(--text-secondary); - display: flex; - flex-wrap: wrap; - gap: 8px; - font-size: 11px; -} - -.droneops-main-telemetry-row { - display: grid; - grid-template-columns: minmax(0, 1.2fr) repeat(5, minmax(0, 1fr)); - gap: 8px; - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 6px 8px; - background: var(--bg-primary); - font-family: var(--font-mono); - font-size: 10px; - color: var(--text-secondary); -} - -.droneops-main-telemetry-row strong { - color: var(--text-primary); - font-weight: 700; -} - -.droneops-main-telemetry-row > span { - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.droneops-main-correlation-row { - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 7px 8px; - background: var(--bg-primary); - display: flex; - flex-direction: column; - gap: 3px; -} - -.droneops-main-correlation-row strong { - font-size: 11px; - color: var(--text-primary); - overflow-wrap: anywhere; -} - -.droneops-main-correlation-row span { - font-size: 10px; - color: var(--text-secondary); -} - -.droneops-main-pane .leaflet-popup-content-wrapper { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); -} - -.droneops-main-pane .leaflet-popup-tip { - background: var(--bg-secondary); -} - -@media (max-width: 1400px) { - .droneops-main-top { - grid-template-columns: 1fr; - } - - .droneops-main-side { - grid-template-columns: repeat(2, minmax(0, 1fr)); - grid-template-rows: none; - } -} - -@media (max-width: 1100px) { - .droneops-main-bottom { - grid-template-columns: 1fr; - } - - .droneops-main-kpis { - grid-template-columns: 1fr; - } -} - -@media (max-width: 780px) { - .droneops-main-side { - grid-template-columns: 1fr; - } - - .droneops-main-telemetry-row { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .droneops-main-telemetry-row > span:first-child { - grid-column: 1 / -1; - } - - .droneops-main-map, - #droneOpsMap { - min-height: 240px; - } -} - -@media (max-width: 900px) { - .droneops-row { - grid-template-columns: repeat(2, minmax(120px, 1fr)); - } - - .droneops-row--actions { - grid-template-columns: repeat(2, minmax(120px, 1fr)); - } -} - -@media (max-width: 560px) { - .droneops-row { - grid-template-columns: 1fr; - } - - .droneops-row--actions { - grid-template-columns: 1fr; - } -} diff --git a/static/js/modes/droneops.js b/static/js/modes/droneops.js deleted file mode 100644 index cfc939c..0000000 --- a/static/js/modes/droneops.js +++ /dev/null @@ -1,1418 +0,0 @@ -/** - * Drone Ops mode frontend. - */ -const DroneOps = (function () { - 'use strict'; - - let initialized = false; - let refreshTimer = null; - let stream = null; - let latestDetections = []; - let latestTracks = []; - let latestCorrelations = []; - let correlationAccess = 'unknown'; - let correlationRefreshCount = 0; - let map = null; - let mapMarkers = null; - let mapTracks = null; - let mapHeat = null; - let mapNeedsAutoFit = true; - let lastCorrelationError = ''; - const DETECTION_START_WAIT_MS = 1500; - const SOURCE_COLORS = { - wifi: '#00d4ff', - bluetooth: '#00ff88', - rf: '#ff9f43', - remote_id: '#f04dff', - }; - - function esc(value) { - return String(value === undefined || value === null ? '' : value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function notify(message, isError = false) { - if (typeof SignalCards !== 'undefined' && SignalCards.showToast) { - SignalCards.showToast(message, isError ? 'error' : 'success'); - return; - } - if (typeof showNotification === 'function') { - showNotification(isError ? 'Drone Ops Error' : 'Drone Ops', message); - return; - } - if (isError) { - console.error(message); - } else { - console.log(message); - } - } - - async function api(path, options = {}) { - const response = await fetch(path, { - headers: { 'Content-Type': 'application/json' }, - ...options, - }); - const data = await response.json().catch(() => ({})); - if (!response.ok || data.status === 'error') { - const error = new Error(data.message || `Request failed (${response.status})`); - error.status = response.status; - throw error; - } - return data; - } - - async function fetchJson(path, options = {}) { - const response = await fetch(path, options); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.message || data.error || `Request failed (${response.status})`); - } - return data; - } - - async function apiOptional(path, options = {}) { - try { - return await api(path, options); - } catch (error) { - return { __error: error }; - } - } - - function confidenceClass(conf) { - if (conf >= 0.8) return 'ok'; - if (conf >= 0.6) return 'warn'; - return 'bad'; - } - - function toNumber(value) { - const n = Number(value); - return Number.isFinite(n) ? n : null; - } - - function hasCoords(lat, lon) { - return lat !== null && lon !== null && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; - } - - function formatCoord(value) { - const num = toNumber(value); - return num === null ? '--' : num.toFixed(5); - } - - function formatMetric(value, decimals = 1, suffix = '') { - const num = toNumber(value); - if (num === null) return '--'; - return `${num.toFixed(decimals)}${suffix}`; - } - - function sourceColor(source) { - return SOURCE_COLORS[String(source || '').toLowerCase()] || '#7f8ea3'; - } - - function defaultMapCenter() { - if (typeof ObserverLocation !== 'undefined' && typeof ObserverLocation.getForModule === 'function') { - const location = ObserverLocation.getForModule('droneops_observerLocation', { fallbackToLatLon: true }); - const lat = toNumber(location?.lat); - const lon = toNumber(location?.lon); - if (hasCoords(lat, lon)) return [lat, lon]; - } - const fallbackLat = toNumber(window.INTERCEPT_DEFAULT_LAT); - const fallbackLon = toNumber(window.INTERCEPT_DEFAULT_LON); - if (hasCoords(fallbackLat, fallbackLon)) return [fallbackLat, fallbackLon]; - return [37.0902, -95.7129]; - } - - function sortedTracksByDetection() { - const grouped = new Map(); - for (const raw of latestTracks) { - const detectionId = Number(raw?.detection_id); - if (!detectionId) continue; - if (!grouped.has(detectionId)) grouped.set(detectionId, []); - grouped.get(detectionId).push(raw); - } - for (const rows of grouped.values()) { - rows.sort((a, b) => String(a?.timestamp || '').localeCompare(String(b?.timestamp || ''))); - } - return grouped; - } - - function detectionTelemetry(detection, tracksByDetection) { - const rows = tracksByDetection.get(Number(detection?.id)) || []; - const latestTrack = rows.length ? rows[rows.length - 1] : null; - const remote = detection && typeof detection.remote_id === 'object' ? detection.remote_id : {}; - const lat = toNumber(latestTrack?.lat ?? remote.lat); - const lon = toNumber(latestTrack?.lon ?? remote.lon); - return { - lat, - lon, - hasPosition: hasCoords(lat, lon), - altitude_m: toNumber(latestTrack?.altitude_m ?? remote.altitude_m), - speed_mps: toNumber(latestTrack?.speed_mps ?? remote.speed_mps), - heading_deg: toNumber(latestTrack?.heading_deg ?? remote.heading_deg), - quality: toNumber(latestTrack?.quality ?? remote.confidence ?? detection?.confidence), - source: latestTrack?.source || detection?.source, - timestamp: latestTrack?.timestamp || detection?.last_seen || '', - uas_id: remote?.uas_id || null, - operator_id: remote?.operator_id || null, - trackRows: rows, - }; - } - - function connectStream() { - if (stream || !initialized) return; - stream = new EventSource('/drone-ops/stream'); - - const handler = (event) => { - let payload = null; - try { - payload = JSON.parse(event.data); - } catch (_) { - return; - } - if (!payload || payload.type === 'keepalive') return; - - if (payload.type === 'detection') { - refreshDetections(); - refreshTracks(); - refreshCorrelations(); - refreshStatus(); - return; - } - - if (payload.type.startsWith('incident_')) { - refreshIncidents(); - refreshStatus(); - return; - } - - if (payload.type.startsWith('action_') || payload.type.startsWith('policy_')) { - refreshActions(); - refreshStatus(); - return; - } - - if (payload.type.startsWith('evidence_')) { - refreshManifests(); - return; - } - }; - - stream.onmessage = handler; - stream.onerror = () => { - if (stream) { - stream.close(); - stream = null; - } - setTimeout(() => { - if (initialized) connectStream(); - }, 2000); - }; - } - - function disconnectStream() { - if (stream) { - stream.close(); - stream = null; - } - } - - function setText(id, value) { - const el = document.getElementById(id); - if (el) el.textContent = value; - } - - function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - function isAgentMode() { - return typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - } - - function wifiRunning() { - if (typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) { - return true; - } - if (typeof isWifiRunning !== 'undefined' && isWifiRunning) { - return true; - } - return false; - } - - function bluetoothRunning() { - if (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.isScanning === 'function' && BluetoothMode.isScanning()) { - return true; - } - if (typeof isBtRunning !== 'undefined' && isBtRunning) { - return true; - } - return false; - } - - function updateSensorsState() { - const active = []; - if (wifiRunning()) active.push('WiFi'); - if (bluetoothRunning()) active.push('Bluetooth'); - setText('droneOpsSensorsState', active.length ? `Running (${active.join(' + ')})` : 'Idle'); - } - - function applySelectOptions(selectId, rows, autoLabel) { - const select = document.getElementById(selectId); - if (!select) return; - const previous = String(select.value || ''); - const seen = new Set(); - const options = [``]; - - for (const row of rows) { - const value = String(row?.value || '').trim(); - if (!value || seen.has(value)) continue; - seen.add(value); - const label = String(row?.label || value); - options.push(``); - } - - select.innerHTML = options.join(''); - if (previous && seen.has(previous)) { - select.value = previous; - } - } - - function readExistingSelectOptions(selectId) { - const select = document.getElementById(selectId); - if (!select) return []; - return Array.from(select.options || []) - .map((opt) => ({ - value: String(opt?.value || '').trim(), - label: String(opt?.textContent || opt?.label || '').trim(), - })) - .filter((opt) => opt.value); - } - - function selectHasChoices(selectId) { - return readExistingSelectOptions(selectId).length > 0; - } - - function ensureSelectValue(select, value, label = '') { - if (!select || !value) return; - const target = String(value); - const existing = Array.from(select.options || []).find((opt) => String(opt.value) === target); - if (!existing) { - const option = document.createElement('option'); - option.value = target; - option.textContent = label || target; - select.appendChild(option); - } - select.value = target; - } - - async function fetchWifiSourceOptions() { - if (isAgentMode()) { - const agentId = typeof currentAgent !== 'undefined' ? currentAgent : null; - if (!agentId || agentId === 'local') return []; - const data = await fetchJson(`/controller/agents/${agentId}?refresh=true`); - const rows = data?.agent?.interfaces?.wifi_interfaces || []; - return rows.map((item) => { - if (typeof item === 'string') { - return { value: item, label: item }; - } - const value = String(item?.name || item?.id || '').trim(); - if (!value) return null; - let label = String(item?.display_name || value); - if (!item?.display_name && item?.type) label += ` (${item.type})`; - if (item?.monitor_capable || item?.supports_monitor) label += ' [Monitor OK]'; - return { value, label }; - }).filter(Boolean); - } - - let rows = []; - try { - const data = await fetchJson('/wifi/interfaces'); - rows = data?.interfaces || []; - } catch (_) { - rows = []; - } - - const mapped = rows.map((item) => { - const value = String(item?.name || item?.id || '').trim(); - if (!value) return null; - let label = value; - const details = []; - if (item?.chipset) details.push(item.chipset); - else if (item?.driver) details.push(item.driver); - if (details.length) label += ` - ${details.join(' | ')}`; - if (item?.type) label += ` (${item.type})`; - if (item?.monitor_capable || item?.supports_monitor) label += ' [Monitor OK]'; - return { value, label }; - }).filter(Boolean); - - if (mapped.length) return mapped; - - if (typeof refreshWifiInterfaces === 'function') { - try { - await Promise.resolve(refreshWifiInterfaces()); - await sleep(250); - } catch (_) { - // Fall back to currently populated options. - } - } - - return readExistingSelectOptions('wifiInterfaceSelect'); - } - - async function fetchBluetoothSourceOptions() { - if (isAgentMode()) { - const agentId = typeof currentAgent !== 'undefined' ? currentAgent : null; - if (!agentId || agentId === 'local') return []; - const data = await fetchJson(`/controller/agents/${agentId}?refresh=true`); - const rows = data?.agent?.interfaces?.bt_adapters || []; - return rows.map((item) => { - if (typeof item === 'string') { - return { value: item, label: item }; - } - const value = String(item?.id || item?.name || '').trim(); - if (!value) return null; - let label = item?.name && item.name !== value ? `${value} - ${item.name}` : value; - if (item?.powered === false) label += ' [DOWN]'; - else if (item?.powered === true) label += ' [UP]'; - return { value, label }; - }).filter(Boolean); - } - - let rows = []; - try { - const data = await fetchJson('/api/bluetooth/capabilities'); - rows = data?.adapters || []; - } catch (_) { - rows = []; - } - - const mapped = rows.map((item) => { - const value = String(item?.id || item?.name || '').trim(); - if (!value) return null; - let label = item?.name && item.name !== value ? `${value} - ${item.name}` : value; - if (item?.powered === false) label += ' [DOWN]'; - else if (item?.powered === true) label += ' [UP]'; - return { value, label }; - }).filter(Boolean); - - if (mapped.length) return mapped; - - if (typeof refreshBtInterfaces === 'function') { - try { - await Promise.resolve(refreshBtInterfaces()); - await sleep(250); - } catch (_) { - // Fall back to currently populated options. - } - } - - return readExistingSelectOptions('btAdapterSelect'); - } - - function applySelectedSourceToModeSelectors() { - const wifiChosen = String(document.getElementById('droneOpsWifiInterfaceSelect')?.value || '').trim(); - if (wifiChosen) { - const wifiSelect = document.getElementById('wifiInterfaceSelect'); - ensureSelectValue(wifiSelect, wifiChosen, wifiChosen); - // Force fresh monitor-interface derivation for the selected adapter. - if (typeof monitorInterface !== 'undefined' && monitorInterface && monitorInterface !== wifiChosen) { - monitorInterface = null; - } - } - - const btChosen = String(document.getElementById('droneOpsBtAdapterSelect')?.value || '').trim(); - if (btChosen) { - const btSelect = document.getElementById('btAdapterSelect'); - ensureSelectValue(btSelect, btChosen, btChosen); - } - } - - async function refreshDetectionSources(silent = false) { - const wifiSelect = document.getElementById('droneOpsWifiInterfaceSelect'); - const btSelect = document.getElementById('droneOpsBtAdapterSelect'); - if (wifiSelect) wifiSelect.innerHTML = ''; - if (btSelect) btSelect.innerHTML = ''; - - const [wifiResult, btResult] = await Promise.allSettled([ - fetchWifiSourceOptions(), - fetchBluetoothSourceOptions(), - ]); - - if (wifiResult.status === 'fulfilled') { - applySelectOptions('droneOpsWifiInterfaceSelect', wifiResult.value, 'Auto WiFi source'); - } else { - applySelectOptions('droneOpsWifiInterfaceSelect', [], 'Auto WiFi source'); - if (!silent) notify(`WiFi source refresh failed: ${wifiResult.reason?.message || 'unknown error'}`, true); - } - - if (btResult.status === 'fulfilled') { - applySelectOptions('droneOpsBtAdapterSelect', btResult.value, 'Auto Bluetooth source'); - } else { - applySelectOptions('droneOpsBtAdapterSelect', [], 'Auto Bluetooth source'); - if (!silent) notify(`Bluetooth source refresh failed: ${btResult.reason?.message || 'unknown error'}`, true); - } - - applySelectedSourceToModeSelectors(); - if (!silent && wifiResult.status === 'fulfilled' && btResult.status === 'fulfilled') { - notify('Detection sources refreshed'); - } - } - - function addFallbackMapLayer(targetMap) { - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors © CARTO', - subdomains: 'abcd', - maxZoom: 19, - className: 'tile-layer-cyan', - }).addTo(targetMap); - } - - async function ensureMap() { - if (map || typeof L === 'undefined') return; - const mapEl = document.getElementById('droneOpsMap'); - if (!mapEl) return; - - map = L.map(mapEl, { - center: defaultMapCenter(), - zoom: 12, - minZoom: 2, - maxZoom: 19, - zoomControl: true, - }); - - if (typeof Settings !== 'undefined' && typeof Settings.createTileLayer === 'function') { - try { - await Settings.init(); - Settings.createTileLayer().addTo(map); - if (typeof Settings.registerMap === 'function') { - Settings.registerMap(map); - } - } catch (_) { - addFallbackMapLayer(map); - } - } else { - addFallbackMapLayer(map); - } - - mapTracks = L.layerGroup().addTo(map); - mapMarkers = L.layerGroup().addTo(map); - if (typeof L.heatLayer === 'function') { - mapHeat = L.heatLayer([], { - radius: 18, - blur: 16, - minOpacity: 0.28, - maxZoom: 18, - gradient: { - 0.2: '#00b7ff', - 0.45: '#00ff88', - 0.7: '#ffb400', - 1.0: '#ff355e', - }, - }).addTo(map); - } - setTimeout(() => { - if (map) map.invalidateSize(); - }, 120); - } - - function invalidateMap() { - mapNeedsAutoFit = true; - if (!map) { - ensureMap(); - return; - } - [80, 240, 500].forEach((delay) => { - setTimeout(() => { - if (map) map.invalidateSize(); - }, delay); - }); - } - - function renderMainSummary(detections, tracksByDetection, filteredTrackCount) { - const sources = new Set(); - let telemetryCount = 0; - - for (const d of detections) { - const source = String(d?.source || '').trim().toLowerCase(); - if (source) sources.add(source); - const telemetry = detectionTelemetry(d, tracksByDetection); - if (telemetry.hasPosition || telemetry.altitude_m !== null || telemetry.speed_mps !== null || telemetry.heading_deg !== null) { - telemetryCount += 1; - } - } - - setText('droneOpsMainSummaryDetections', String(detections.length)); - setText('droneOpsMainSummarySources', String(sources.size)); - setText('droneOpsMainSummaryTracks', String(filteredTrackCount)); - setText('droneOpsMainSummaryTelemetry', String(telemetryCount)); - if (correlationAccess === 'restricted') { - setText('droneOpsMainSummaryCorrelations', 'Role'); - } else { - setText('droneOpsMainSummaryCorrelations', String(latestCorrelations.length)); - } - setText('droneOpsMainLastUpdate', new Date().toLocaleTimeString()); - } - - function renderMainDetections(detections, tracksByDetection) { - const container = document.getElementById('droneOpsMainDetections'); - if (!container) return; - - if (!detections.length) { - container.innerHTML = '
No detections yet
'; - return; - } - - container.innerHTML = detections.slice(0, 150).map((d) => { - const conf = Number(d.confidence || 0); - const cls = confidenceClass(conf); - const telemetry = detectionTelemetry(d, tracksByDetection); - const ridSummary = []; - if (telemetry.uas_id) ridSummary.push(`uas: ${esc(telemetry.uas_id)}`); - if (telemetry.operator_id) ridSummary.push(`operator: ${esc(telemetry.operator_id)}`); - - return `
-
- ${esc(d.identifier)} - ${Math.round(conf * 100)}% -
-
- source: ${esc(d.source || 'unknown')} - class: ${esc(d.classification || 'unknown')} - last: ${esc(d.last_seen || '')} -
-
- lat: ${formatCoord(telemetry.lat)} - lon: ${formatCoord(telemetry.lon)} - alt: ${formatMetric(telemetry.altitude_m, 1, ' m')} - spd: ${formatMetric(telemetry.speed_mps, 1, ' m/s')} - hdg: ${formatMetric(telemetry.heading_deg, 0, '°')} -
- ${ridSummary.length ? `
${ridSummary.join(' • ')}
` : ''} -
`; - }).join(''); - } - - function renderMainTelemetry(detections, filteredTracks) { - const container = document.getElementById('droneOpsMainTelemetry'); - if (!container) return; - - const detectionById = new Map(detections.map((d) => [Number(d.id), d])); - - if (filteredTracks.length) { - const rows = filteredTracks - .slice() - .sort((a, b) => String(b?.timestamp || '').localeCompare(String(a?.timestamp || ''))) - .slice(0, 180); - - container.innerHTML = rows.map((t) => { - const detection = detectionById.get(Number(t.detection_id)); - const label = detection?.identifier || `#${Number(t.detection_id)}`; - const quality = toNumber(t.quality); - return `
- ${esc(label)} ${esc(t.timestamp || '')} - ${formatCoord(t.lat)}, ${formatCoord(t.lon)} - alt ${formatMetric(t.altitude_m, 1, 'm')} - spd ${formatMetric(t.speed_mps, 1, 'm/s')} - hdg ${formatMetric(t.heading_deg, 0, '°')} - ${esc(t.source || detection?.source || 'unknown')} • q ${quality === null ? '--' : quality.toFixed(2)} -
`; - }).join(''); - return; - } - - const tracksByDetection = sortedTracksByDetection(); - const telemetryRows = detections - .map((d) => ({ detection: d, telemetry: detectionTelemetry(d, tracksByDetection) })) - .filter((entry) => entry.telemetry.hasPosition || entry.telemetry.altitude_m !== null || entry.telemetry.speed_mps !== null || entry.telemetry.heading_deg !== null); - - if (!telemetryRows.length) { - container.innerHTML = '
No telemetry yet
'; - return; - } - - container.innerHTML = telemetryRows.slice(0, 120).map((entry) => { - const d = entry.detection; - const telemetry = entry.telemetry; - return `
- ${esc(d.identifier)} ${esc(telemetry.timestamp || '')} - ${formatCoord(telemetry.lat)}, ${formatCoord(telemetry.lon)} - alt ${formatMetric(telemetry.altitude_m, 1, 'm')} - spd ${formatMetric(telemetry.speed_mps, 1, 'm/s')} - hdg ${formatMetric(telemetry.heading_deg, 0, '°')} - ${esc(d.source || 'unknown')} -
`; - }).join(''); - } - - function renderMainCorrelations() { - const container = document.getElementById('droneOpsMainCorrelations'); - if (!container) return; - - if (correlationAccess === 'restricted') { - container.innerHTML = '
Correlation data requires analyst role
'; - return; - } - - if (correlationAccess === 'error') { - container.innerHTML = '
Correlation data unavailable
'; - return; - } - - if (!latestCorrelations.length) { - container.innerHTML = '
No correlations yet
'; - return; - } - - container.innerHTML = latestCorrelations.slice(0, 80).map((row) => { - const confidence = Number(row?.confidence || 0); - const cls = confidenceClass(confidence); - return `
- ${esc(row.drone_identifier || 'unknown')} → ${esc(row.operator_identifier || 'unknown')} - method: ${esc(row.method || 'n/a')} • ${esc(row.created_at || '')} - ${Math.round(confidence * 100)}% -
`; - }).join(''); - } - - function renderMapDetections(detections, tracksByDetection) { - if (!map) { - if (typeof L === 'undefined' || !document.getElementById('droneOpsMap')) return; - ensureMap().then(() => { - if (map) renderMapDetections(detections, tracksByDetection); - }).catch(() => {}); - return; - } - if (!mapMarkers || !mapTracks) return; - - mapMarkers.clearLayers(); - mapTracks.clearLayers(); - - const heatPoints = []; - const boundsPoints = []; - - for (const d of detections) { - const telemetry = detectionTelemetry(d, tracksByDetection); - const color = sourceColor(telemetry.source || d.source); - const pathPoints = []; - for (const row of telemetry.trackRows) { - const lat = toNumber(row?.lat); - const lon = toNumber(row?.lon); - if (!hasCoords(lat, lon)) continue; - const latLng = [lat, lon]; - pathPoints.push(latLng); - boundsPoints.push(latLng); - const intensity = Math.max(0.2, Math.min(1, toNumber(row?.quality ?? d.confidence) ?? 0.5)); - heatPoints.push([lat, lon, intensity]); - } - - if (pathPoints.length > 1) { - L.polyline(pathPoints, { - color, - weight: 2, - opacity: 0.75, - lineJoin: 'round', - }).addTo(mapTracks); - } - - if (telemetry.hasPosition) { - const latLng = [telemetry.lat, telemetry.lon]; - boundsPoints.push(latLng); - const intensity = Math.max(0.2, Math.min(1, telemetry.quality ?? toNumber(d.confidence) ?? 0.5)); - heatPoints.push([telemetry.lat, telemetry.lon, intensity]); - - L.circleMarker(latLng, { - radius: 6, - color, - fillColor: color, - fillOpacity: 0.88, - weight: 2, - }).bindPopup(` -
-
${esc(d.identifier)}
-
Source: ${esc(d.source || 'unknown')}
-
Confidence: ${Math.round(Number(d.confidence || 0) * 100)}%
-
Lat/Lon: ${formatCoord(telemetry.lat)}, ${formatCoord(telemetry.lon)}
-
Alt: ${formatMetric(telemetry.altitude_m, 1, ' m')} • Spd: ${formatMetric(telemetry.speed_mps, 1, ' m/s')}
-
Heading: ${formatMetric(telemetry.heading_deg, 0, '°')}
-
- `).addTo(mapMarkers); - } - } - - if (mapHeat && typeof mapHeat.setLatLngs === 'function') { - mapHeat.setLatLngs(heatPoints); - } - - if (boundsPoints.length && mapNeedsAutoFit) { - map.fitBounds(L.latLngBounds(boundsPoints), { padding: [24, 24], maxZoom: 16 }); - mapNeedsAutoFit = false; - } - - if (!boundsPoints.length) { - setText('droneOpsMapMeta', 'No geospatial telemetry yet'); - } else { - setText('droneOpsMapMeta', `${boundsPoints.length} geo points • ${heatPoints.length} heat samples`); - } - } - - function renderMainPane() { - const pane = document.getElementById('droneOpsMainPane'); - if (!pane) return; - - const detections = Array.isArray(latestDetections) ? latestDetections : []; - const detectionIds = new Set(detections.map((d) => Number(d.id)).filter(Boolean)); - const filteredTracks = (Array.isArray(latestTracks) ? latestTracks : []) - .filter((track) => detectionIds.has(Number(track?.detection_id))); - const tracksByDetection = sortedTracksByDetection(); - - renderMainSummary(detections, tracksByDetection, filteredTracks.length); - renderMainDetections(detections, tracksByDetection); - renderMainTelemetry(detections, filteredTracks); - renderMainCorrelations(); - renderMapDetections(detections, tracksByDetection); - } - - async function ensureSessionForDetection() { - try { - const status = await api('/drone-ops/status'); - if (!status.active_session) { - await api('/drone-ops/session/start', { - method: 'POST', - body: JSON.stringify({ mode: 'passive' }), - }); - } - } catch (_) { - // Detection can still run without an explicit Drone Ops session. - } - } - - async function startWifiDetection() { - if (isAgentMode()) { - if (typeof WiFiMode !== 'undefined') { - if (WiFiMode.init) WiFiMode.init(); - if (WiFiMode.startDeepScan) { - await WiFiMode.startDeepScan(); - await sleep(DETECTION_START_WAIT_MS); - if (wifiRunning()) return; - } - } - throw new Error('Unable to start WiFi detection in agent mode'); - } - - if (typeof WiFiMode !== 'undefined') { - if (WiFiMode.init) WiFiMode.init(); - if (WiFiMode.startDeepScan) { - await WiFiMode.startDeepScan(); - await sleep(DETECTION_START_WAIT_MS); - if (wifiRunning()) return; - } - } - - if (typeof startWifiScan === 'function') { - await Promise.resolve(startWifiScan()); - await sleep(DETECTION_START_WAIT_MS); - if (wifiRunning()) return; - } - - throw new Error('WiFi scan did not start'); - } - - async function startBluetoothDetection() { - if (typeof startBtScan === 'function') { - await Promise.resolve(startBtScan()); - await sleep(DETECTION_START_WAIT_MS); - if (bluetoothRunning()) return; - } - - if (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.startScan === 'function') { - await BluetoothMode.startScan(); - await sleep(DETECTION_START_WAIT_MS); - if (bluetoothRunning()) return; - } - - throw new Error('Bluetooth scan did not start'); - } - - async function stopWifiDetection() { - if (isAgentMode() && typeof WiFiMode !== 'undefined' && typeof WiFiMode.stopScan === 'function') { - await WiFiMode.stopScan(); - return; - } - - if (typeof stopWifiScan === 'function') { - await Promise.resolve(stopWifiScan()); - return; - } - - if (typeof WiFiMode !== 'undefined' && typeof WiFiMode.stopScan === 'function') { - await WiFiMode.stopScan(); - return; - } - } - - async function stopBluetoothDetection() { - if (typeof stopBtScan === 'function') { - await Promise.resolve(stopBtScan()); - return; - } - - if (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.stopScan === 'function') { - await BluetoothMode.stopScan(); - return; - } - } - - async function refreshStatus() { - try { - const data = await api('/drone-ops/status'); - const active = data.active_session; - const policy = data.policy || {}; - const counts = data.counts || {}; - - setText('droneOpsSessionValue', active ? `${active.mode.toUpperCase()} #${active.id}` : 'Idle'); - setText('droneOpsArmedValue', policy.armed ? 'Yes' : 'No'); - setText('droneOpsDetectionCount', String(counts.detections || 0)); - setText('droneOpsIncidentCount', String(counts.incidents_open || 0)); - updateSensorsState(); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshDetections() { - const source = document.getElementById('droneOpsSourceFilter')?.value || ''; - const min = parseFloat(document.getElementById('droneOpsConfidenceFilter')?.value || '0.5'); - - try { - const data = await api(`/drone-ops/detections?limit=500&source=${encodeURIComponent(source)}&min_confidence=${encodeURIComponent(isNaN(min) ? 0.5 : min)}`); - const detections = data.detections || []; - latestDetections = detections; - const container = document.getElementById('droneOpsDetections'); - if (!container) { - renderMainPane(); - return; - } - - if (!detections.length) { - container.innerHTML = '
No detections yet
'; - renderMainPane(); - return; - } - - container.innerHTML = detections.map((d) => { - const conf = Number(d.confidence || 0); - const confPct = Math.round(conf * 100); - const cls = confidenceClass(conf); - return `
-
${esc(d.identifier)} ${confPct}%
-
- source: ${esc(d.source)} - class: ${esc(d.classification || 'unknown')} - last seen: ${esc(d.last_seen || '')} -
-
- -
-
`; - }).join(''); - renderMainPane(); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshTracks() { - try { - const data = await api('/drone-ops/tracks?limit=3000'); - latestTracks = Array.isArray(data.tracks) ? data.tracks : []; - renderMainPane(); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshCorrelations() { - const refreshFlag = correlationRefreshCount % 4 === 0; - correlationRefreshCount += 1; - const result = await apiOptional(`/drone-ops/correlations?min_confidence=0.5&limit=120&refresh=${refreshFlag ? 'true' : 'false'}`); - if (result.__error) { - if (result.__error?.status === 403) { - correlationAccess = 'restricted'; - latestCorrelations = []; - renderMainPane(); - return; - } - correlationAccess = 'error'; - latestCorrelations = []; - const message = String(result.__error?.message || 'Unable to load correlations'); - if (message !== lastCorrelationError) { - lastCorrelationError = message; - notify(message, true); - } - renderMainPane(); - return; - } - - correlationAccess = 'ok'; - lastCorrelationError = ''; - latestCorrelations = Array.isArray(result.correlations) ? result.correlations : []; - renderMainPane(); - } - - async function refreshIncidents() { - try { - const data = await api('/drone-ops/incidents?limit=100'); - const incidents = data.incidents || []; - const container = document.getElementById('droneOpsIncidents'); - if (!container) return; - - if (!incidents.length) { - container.innerHTML = '
No incidents
'; - return; - } - - container.innerHTML = incidents.map((i) => ` -
-
#${Number(i.id)} ${esc(i.title)} - ${esc(i.status)} -
-
- severity: ${esc(i.severity)} - opened: ${esc(i.opened_at || '')} -
-
- - -
-
- `).join(''); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshActions() { - try { - const data = await api('/drone-ops/actions/requests?limit=100'); - const rows = data.requests || []; - const container = document.getElementById('droneOpsActions'); - if (!container) return; - - if (!rows.length) { - container.innerHTML = '
No action requests
'; - return; - } - - container.innerHTML = rows.map((r) => { - const statusClass = r.status === 'executed' ? 'ok' : (r.status === 'approved' ? 'warn' : 'bad'); - return `
-
Request #${Number(r.id)} - ${esc(r.status)} -
-
- incident: ${Number(r.incident_id)} - action: ${esc(r.action_type)} - requested by: ${esc(r.requested_by)} -
-
- - -
-
`; - }).join(''); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshManifests() { - const incident = parseInt(document.getElementById('droneOpsManifestIncident')?.value || '0', 10); - const container = document.getElementById('droneOpsManifests'); - if (!container) return; - - if (!incident) { - container.innerHTML = '
Enter incident ID to list manifests
'; - return; - } - - try { - const data = await api(`/drone-ops/evidence/${incident}/manifests?limit=50`); - const rows = data.manifests || []; - if (!rows.length) { - container.innerHTML = '
No manifests
'; - return; - } - container.innerHTML = rows.map((m) => `
-
Manifest #${Number(m.id)}
-
- algo: ${esc(m.hash_algo)} - created: ${esc(m.created_at || '')} -
-
`).join(''); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshAll() { - await Promise.all([ - refreshStatus(), - refreshDetections(), - refreshTracks(), - refreshCorrelations(), - refreshIncidents(), - refreshActions(), - refreshManifests(), - ]); - renderMainPane(); - } - - async function startSession(mode) { - try { - await api('/drone-ops/session/start', { - method: 'POST', - body: JSON.stringify({ mode }), - }); - notify(`Started ${mode} session`); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function stopSession() { - try { - await api('/drone-ops/session/stop', { method: 'POST', body: JSON.stringify({}) }); - notify('Session stopped'); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function arm() { - const incident = parseInt(document.getElementById('droneOpsArmIncident')?.value || '0', 10); - const reason = String(document.getElementById('droneOpsArmReason')?.value || '').trim(); - if (!incident || !reason) { - notify('Incident ID and arming reason are required', true); - return; - } - try { - await api('/drone-ops/actions/arm', { - method: 'POST', - body: JSON.stringify({ incident_id: incident, reason }), - }); - notify('Action plane armed'); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function disarm() { - try { - await api('/drone-ops/actions/disarm', { method: 'POST', body: JSON.stringify({}) }); - notify('Action plane disarmed'); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function startDetection() { - const useWifi = Boolean(document.getElementById('droneOpsDetectWifi')?.checked); - const useBluetooth = Boolean(document.getElementById('droneOpsDetectBluetooth')?.checked); - - if (!useWifi && !useBluetooth) { - notify('Select at least one source (WiFi or Bluetooth)', true); - return; - } - - const needsWifiSources = useWifi && !selectHasChoices('droneOpsWifiInterfaceSelect'); - const needsBtSources = useBluetooth && !selectHasChoices('droneOpsBtAdapterSelect'); - if (needsWifiSources || needsBtSources) { - await refreshDetectionSources(true); - } - - applySelectedSourceToModeSelectors(); - await ensureSessionForDetection(); - - const started = []; - const failed = []; - - if (useWifi) { - try { - await startWifiDetection(); - started.push('WiFi'); - } catch (e) { - failed.push(`WiFi: ${e.message}`); - } - } - - if (useBluetooth) { - try { - await startBluetoothDetection(); - started.push('Bluetooth'); - } catch (e) { - failed.push(`Bluetooth: ${e.message}`); - } - } - - updateSensorsState(); - await refreshStatus(); - await refreshDetections(); - await refreshTracks(); - await refreshCorrelations(); - - if (!started.length) { - notify(`Detection start failed (${failed.join(' | ')})`, true); - return; - } - - if (failed.length) { - notify(`Started: ${started.join(', ')} | Errors: ${failed.join(' | ')}`, true); - return; - } - - notify(`Detection started: ${started.join(', ')}`); - } - - async function stopDetection() { - const errors = []; - - if (wifiRunning()) { - try { - await stopWifiDetection(); - } catch (e) { - errors.push(`WiFi: ${e.message}`); - } - } - - if (bluetoothRunning()) { - try { - await stopBluetoothDetection(); - } catch (e) { - errors.push(`Bluetooth: ${e.message}`); - } - } - - await sleep(300); - updateSensorsState(); - await refreshStatus(); - await refreshTracks(); - - if (errors.length) { - notify(`Detection stop issues: ${errors.join(' | ')}`, true); - return; - } - notify('Detection stopped'); - } - - async function createIncident() { - const title = String(document.getElementById('droneOpsIncidentTitle')?.value || '').trim(); - const severity = String(document.getElementById('droneOpsIncidentSeverity')?.value || 'medium'); - if (!title) { - notify('Incident title is required', true); - return; - } - - try { - const data = await api('/drone-ops/incidents', { - method: 'POST', - body: JSON.stringify({ title, severity }), - }); - notify(`Incident #${data.incident?.id || ''} created`); - refreshIncidents(); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function closeIncident(incidentId) { - try { - await api(`/drone-ops/incidents/${incidentId}`, { - method: 'PUT', - body: JSON.stringify({ status: 'closed' }), - }); - notify(`Incident #${incidentId} closed`); - refreshIncidents(); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function openIncidentFromDetection(detectionId) { - const title = `Drone detection #${detectionId}`; - try { - const created = await api('/drone-ops/incidents', { - method: 'POST', - body: JSON.stringify({ title, severity: 'medium' }), - }); - const incidentId = created.incident.id; - await api(`/drone-ops/incidents/${incidentId}/artifacts`, { - method: 'POST', - body: JSON.stringify({ - artifact_type: 'detection', - artifact_ref: String(detectionId), - metadata: { auto_linked: true }, - }), - }); - notify(`Incident #${incidentId} opened for detection #${detectionId}`); - refreshIncidents(); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function attachLatestDetections(incidentId) { - try { - const data = await api('/drone-ops/detections?limit=10&min_confidence=0.6'); - const detections = data.detections || []; - if (!detections.length) { - notify('No high-confidence detections to attach', true); - return; - } - for (const d of detections) { - await api(`/drone-ops/incidents/${incidentId}/artifacts`, { - method: 'POST', - body: JSON.stringify({ - artifact_type: 'detection', - artifact_ref: String(d.id), - metadata: { source: d.source, identifier: d.identifier }, - }), - }); - } - notify(`Attached ${detections.length} detections to incident #${incidentId}`); - refreshIncidents(); - } catch (e) { - notify(e.message, true); - } - } - - async function requestAction() { - const incident = parseInt(document.getElementById('droneOpsActionIncident')?.value || '0', 10); - const actionType = String(document.getElementById('droneOpsActionType')?.value || '').trim(); - - if (!incident || !actionType) { - notify('Incident ID and action type are required', true); - return; - } - - try { - await api('/drone-ops/actions/request', { - method: 'POST', - body: JSON.stringify({ - incident_id: incident, - action_type: actionType, - payload: {}, - }), - }); - notify('Action request submitted'); - refreshActions(); - } catch (e) { - notify(e.message, true); - } - } - - async function approveAction(requestId) { - try { - await api(`/drone-ops/actions/approve/${requestId}`, { - method: 'POST', - body: JSON.stringify({ decision: 'approved' }), - }); - notify(`Request #${requestId} approved`); - refreshActions(); - } catch (e) { - notify(e.message, true); - } - } - - async function executeAction(requestId) { - try { - await api(`/drone-ops/actions/execute/${requestId}`, { - method: 'POST', - body: JSON.stringify({}), - }); - notify(`Request #${requestId} executed`); - refreshActions(); - } catch (e) { - notify(e.message, true); - } - } - - async function generateManifest() { - const incident = parseInt(document.getElementById('droneOpsManifestIncident')?.value || '0', 10); - if (!incident) { - notify('Incident ID is required to generate manifest', true); - return; - } - try { - await api(`/drone-ops/evidence/${incident}/manifest`, { - method: 'POST', - body: JSON.stringify({}), - }); - notify(`Manifest generated for incident #${incident}`); - refreshManifests(); - } catch (e) { - notify(e.message, true); - } - } - - function init() { - if (initialized) { - refreshDetectionSources(true); - refreshAll(); - invalidateMap(); - return; - } - initialized = true; - mapNeedsAutoFit = true; - ensureMap(); - refreshDetectionSources(true); - refreshAll(); - connectStream(); - refreshTimer = setInterval(refreshAll, 15000); - } - - function destroy() { - initialized = false; - mapNeedsAutoFit = true; - if (refreshTimer) { - clearInterval(refreshTimer); - refreshTimer = null; - } - disconnectStream(); - } - - return { - init, - destroy, - refreshStatus, - refreshDetections, - refreshTracks, - refreshDetectionSources, - refreshIncidents, - refreshActions, - startDetection, - stopDetection, - invalidateMap, - startSession, - stopSession, - arm, - disarm, - createIncident, - closeIncident, - openIncidentFromDetection, - attachLatestDetections, - requestAction, - approveAction, - executeAction, - generateManifest, - }; -})(); diff --git a/templates/index.html b/templates/index.html index f677cb4..abe7b92 100644 --- a/templates/index.html +++ b/templates/index.html @@ -66,7 +66,6 @@ aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}", tscm: "{{ url_for('static', filename='css/modes/tscm.css') }}", analytics: "{{ url_for('static', filename='css/modes/analytics.css') }}", - droneops: "{{ url_for('static', filename='css/modes/droneops.css') }}?v={{ version }}&r=droneops6", spystations: "{{ url_for('static', filename='css/modes/spy-stations.css') }}", meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}", sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}", @@ -282,10 +281,6 @@ TSCM - - - -
- -
- - - - -
- -
-
Detection Sources
-
- - - -
-
-
Sensors: Idle
- -
- - - - -
- - - -
-

- Detections - -

-
-
- - - -
-
-
No detections yet
-
-
-
- -
-

- Incidents - -

-
-
- - - -
-
-
No incidents
-
-
-
- -
-

- Actions - -

-
-
- - - -
-
-
No action requests
-
-
-
- -
-

- Evidence - -

-
-
- - -
-
-
No manifests
-
-
-
- diff --git a/templates/partials/nav.html b/templates/partials/nav.html index f5140fa..0e5d3f5 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -133,7 +133,6 @@
{{ mode_item('tscm', 'TSCM', '') }} - {{ mode_item('droneops', 'Drone Ops', '') }} {{ mode_item('analytics', 'Analytics', '') }} {{ mode_item('spystations', 'Spy Stations', '') }} {{ mode_item('websdr', 'WebSDR', '') }} @@ -216,7 +215,6 @@ {{ mobile_item('meshtastic', 'Mesh', '') }} {# Intel #} {{ mobile_item('tscm', 'TSCM', '') }} - {{ mobile_item('droneops', 'Drone Ops', '') }} {{ mobile_item('analytics', 'Analytics', '') }} {{ mobile_item('spystations', 'Spy', '') }} {{ mobile_item('websdr', 'WebSDR', '') }} diff --git a/tests/test_drone_ops_policy.py b/tests/test_drone_ops_policy.py deleted file mode 100644 index a58c2ba..0000000 --- a/tests/test_drone_ops_policy.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Tests for Drone Ops policy helpers and service policy behavior.""" - -from utils.drone.policy import required_approvals_for_action -from utils.drone.service import DroneOpsService - - -def test_required_approvals_policy_helper(): - assert required_approvals_for_action('passive_scan') == 1 - assert required_approvals_for_action('wifi_deauth_test') == 2 - - -def test_service_required_approvals_matches_policy_helper(): - assert DroneOpsService.required_approvals('passive_capture') == required_approvals_for_action('passive_capture') - assert DroneOpsService.required_approvals('active_test') == required_approvals_for_action('active_test') - - -def test_service_arm_disarm_policy_state(): - service = DroneOpsService() - - armed = service.arm_actions( - actor='operator-1', - reason='controlled testing', - incident_id=42, - duration_seconds=5, - ) - assert armed['armed'] is True - assert armed['armed_by'] == 'operator-1' - assert armed['arm_reason'] == 'controlled testing' - assert armed['arm_incident_id'] == 42 - assert armed['armed_until'] is not None - - disarmed = service.disarm_actions(actor='operator-1', reason='test complete') - assert disarmed['armed'] is False - assert disarmed['armed_by'] is None - assert disarmed['arm_reason'] is None - assert disarmed['arm_incident_id'] is None diff --git a/tests/test_drone_ops_remote_id.py b/tests/test_drone_ops_remote_id.py deleted file mode 100644 index 7bd5a36..0000000 --- a/tests/test_drone_ops_remote_id.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for Drone Ops Remote ID decoder helpers.""" - -from __future__ import annotations - -import json - -from utils.drone.remote_id import decode_remote_id_payload - - -def test_decode_remote_id_from_dict_payload(): - payload = { - 'remote_id': { - 'uas_id': 'UAS-001', - 'operator_id': 'OP-007', - 'lat': 37.7749, - 'lon': -122.4194, - 'altitude_m': 121.5, - 'speed_mps': 12.3, - 'heading_deg': 270.0, - } - } - - decoded = decode_remote_id_payload(payload) - assert decoded['detected'] is True - assert decoded['source_format'] == 'dict' - assert decoded['uas_id'] == 'UAS-001' - assert decoded['operator_id'] == 'OP-007' - assert decoded['lat'] == 37.7749 - assert decoded['lon'] == -122.4194 - assert decoded['altitude_m'] == 121.5 - assert decoded['speed_mps'] == 12.3 - assert decoded['heading_deg'] == 270.0 - assert decoded['confidence'] > 0.0 - - -def test_decode_remote_id_from_json_string(): - payload = json.dumps({ - 'uas_id': 'RID-ABC', - 'lat': 35.0, - 'lon': -115.0, - 'altitude': 80, - }) - - decoded = decode_remote_id_payload(payload) - assert decoded['detected'] is True - assert decoded['source_format'] == 'json' - assert decoded['uas_id'] == 'RID-ABC' - assert decoded['lat'] == 35.0 - assert decoded['lon'] == -115.0 - assert decoded['altitude_m'] == 80.0 - - -def test_decode_remote_id_from_raw_text_is_not_detected(): - decoded = decode_remote_id_payload('not-a-remote-id-payload') - assert decoded['detected'] is False - assert decoded['source_format'] == 'raw' - assert decoded['uas_id'] is None - assert decoded['operator_id'] is None - assert isinstance(decoded['raw'], dict) - assert decoded['raw']['raw'] == 'not-a-remote-id-payload' diff --git a/tests/test_drone_ops_routes.py b/tests/test_drone_ops_routes.py deleted file mode 100644 index 28e8f84..0000000 --- a/tests/test_drone_ops_routes.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Tests for Drone Ops API routes.""" - -from __future__ import annotations - -import pytest - -import utils.database as db_mod -from utils.drone import get_drone_ops_service - - -def _set_identity(client, role: str, username: str = 'tester') -> None: - with client.session_transaction() as sess: - sess['logged_in'] = True - sess['role'] = role - sess['username'] = username - - -def _clear_drone_tables() -> None: - with db_mod.get_db() as conn: - conn.execute('DELETE FROM action_audit_log') - conn.execute('DELETE FROM action_approvals') - conn.execute('DELETE FROM action_requests') - conn.execute('DELETE FROM evidence_manifests') - conn.execute('DELETE FROM drone_incident_artifacts') - conn.execute('DELETE FROM drone_tracks') - conn.execute('DELETE FROM drone_correlations') - conn.execute('DELETE FROM drone_detections') - conn.execute('DELETE FROM drone_incidents') - conn.execute('DELETE FROM drone_sessions') - - -@pytest.fixture(scope='module', autouse=True) -def isolated_drone_db(tmp_path_factory): - original_db_dir = db_mod.DB_DIR - original_db_path = db_mod.DB_PATH - - tmp_dir = tmp_path_factory.mktemp('drone_ops_db') - db_mod.DB_DIR = tmp_dir - db_mod.DB_PATH = tmp_dir / 'test_intercept.db' - - if hasattr(db_mod._local, 'connection') and db_mod._local.connection is not None: - db_mod._local.connection.close() - db_mod._local.connection = None - - db_mod.init_db() - yield - - db_mod.close_db() - db_mod.DB_DIR = original_db_dir - db_mod.DB_PATH = original_db_path - db_mod._local.connection = None - - -@pytest.fixture(autouse=True) -def clean_drone_state(): - db_mod.init_db() - _clear_drone_tables() - get_drone_ops_service().disarm_actions(actor='test-reset', reason='test setup') - yield - _clear_drone_tables() - get_drone_ops_service().disarm_actions(actor='test-reset', reason='test teardown') - - -def test_start_session_requires_operator_role(client): - _set_identity(client, role='viewer') - response = client.post('/drone-ops/session/start', json={'mode': 'passive'}) - assert response.status_code == 403 - data = response.get_json() - assert data['required_role'] == 'operator' - - -def test_session_lifecycle_and_status(client): - _set_identity(client, role='operator', username='op1') - - started = client.post('/drone-ops/session/start', json={'mode': 'passive'}) - assert started.status_code == 200 - start_data = started.get_json() - assert start_data['status'] == 'success' - assert start_data['session']['mode'] == 'passive' - assert start_data['session']['active'] is True - - status = client.get('/drone-ops/status') - assert status.status_code == 200 - status_data = status.get_json() - assert status_data['status'] == 'success' - assert status_data['active_session'] is not None - assert status_data['active_session']['id'] == start_data['session']['id'] - - stopped = client.post('/drone-ops/session/stop', json={'id': start_data['session']['id']}) - assert stopped.status_code == 200 - stop_data = stopped.get_json() - assert stop_data['status'] == 'success' - assert stop_data['session']['active'] is False - - -def test_detection_ingest_visible_via_endpoint(client): - _set_identity(client, role='operator', username='op1') - start_resp = client.post('/drone-ops/session/start', json={'mode': 'passive'}) - assert start_resp.status_code == 200 - - service = get_drone_ops_service() - service.ingest_event( - mode='wifi', - event={ - 'bssid': '60:60:1F:AA:BB:CC', - 'ssid': 'DJI-OPS-TEST', - }, - event_type='network_update', - ) - - _set_identity(client, role='viewer', username='viewer1') - response = client.get('/drone-ops/detections?source=wifi&min_confidence=0.5') - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'success' - assert data['count'] >= 1 - detection = data['detections'][0] - assert detection['source'] == 'wifi' - assert detection['confidence'] >= 0.5 - - -def test_incident_artifact_and_manifest_flow(client): - _set_identity(client, role='operator', username='op1') - created = client.post( - '/drone-ops/incidents', - json={'title': 'Unidentified UAS', 'severity': 'high'}, - ) - assert created.status_code == 201 - incident = created.get_json()['incident'] - incident_id = incident['id'] - - artifact_resp = client.post( - f'/drone-ops/incidents/{incident_id}/artifacts', - json={'artifact_type': 'detection', 'artifact_ref': '12345'}, - ) - assert artifact_resp.status_code == 201 - - _set_identity(client, role='analyst', username='analyst1') - manifest_resp = client.post(f'/drone-ops/evidence/{incident_id}/manifest', json={}) - assert manifest_resp.status_code == 201 - manifest = manifest_resp.get_json()['manifest'] - assert manifest['manifest']['integrity']['algorithm'] == 'sha256' - assert len(manifest['manifest']['integrity']['digest']) == 64 - - _set_identity(client, role='viewer', username='viewer1') - listed = client.get(f'/drone-ops/evidence/{incident_id}/manifests') - assert listed.status_code == 200 - listed_data = listed.get_json() - assert listed_data['count'] == 1 - assert listed_data['manifests'][0]['id'] == manifest['id'] - - -def test_action_execution_requires_arming_and_two_approvals(client): - _set_identity(client, role='operator', username='op1') - incident_resp = client.post('/drone-ops/incidents', json={'title': 'Action Gate Test'}) - incident_id = incident_resp.get_json()['incident']['id'] - - request_resp = client.post( - '/drone-ops/actions/request', - json={ - 'incident_id': incident_id, - 'action_type': 'wifi_deauth_test', - 'payload': {'target': 'aa:bb:cc:dd:ee:ff'}, - }, - ) - assert request_resp.status_code == 201 - request_id = request_resp.get_json()['request']['id'] - - not_armed_resp = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) - assert not_armed_resp.status_code == 403 - assert 'not armed' in not_armed_resp.get_json()['message'].lower() - - arm_resp = client.post( - '/drone-ops/actions/arm', - json={'incident_id': incident_id, 'reason': 'controlled test'}, - ) - assert arm_resp.status_code == 200 - assert arm_resp.get_json()['policy']['armed'] is True - - insufficient_resp = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) - assert insufficient_resp.status_code == 400 - assert 'insufficient approvals' in insufficient_resp.get_json()['message'].lower() - - _set_identity(client, role='supervisor', username='supervisor-a') - approve_one = client.post(f'/drone-ops/actions/approve/{request_id}', json={'decision': 'approved'}) - assert approve_one.status_code == 200 - - _set_identity(client, role='operator', username='op1') - still_insufficient = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) - assert still_insufficient.status_code == 400 - - _set_identity(client, role='supervisor', username='supervisor-b') - approve_two = client.post(f'/drone-ops/actions/approve/{request_id}', json={'decision': 'approved'}) - assert approve_two.status_code == 200 - assert approve_two.get_json()['request']['status'] == 'approved' - - _set_identity(client, role='operator', username='op1') - executed = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) - assert executed.status_code == 200 - assert executed.get_json()['request']['status'] == 'executed' - - -def test_passive_action_executes_after_single_approval(client): - _set_identity(client, role='operator', username='op1') - incident_resp = client.post('/drone-ops/incidents', json={'title': 'Passive Action Test'}) - incident_id = incident_resp.get_json()['incident']['id'] - - request_resp = client.post( - '/drone-ops/actions/request', - json={'incident_id': incident_id, 'action_type': 'passive_spectrum_capture'}, - ) - request_id = request_resp.get_json()['request']['id'] - - arm_resp = client.post( - '/drone-ops/actions/arm', - json={'incident_id': incident_id, 'reason': 'passive validation'}, - ) - assert arm_resp.status_code == 200 - - _set_identity(client, role='supervisor', username='supervisor-a') - approve_resp = client.post(f'/drone-ops/actions/approve/{request_id}', json={'decision': 'approved'}) - assert approve_resp.status_code == 200 - assert approve_resp.get_json()['request']['status'] == 'approved' - - _set_identity(client, role='operator', username='op1') - execute_resp = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) - assert execute_resp.status_code == 200 - assert execute_resp.get_json()['request']['status'] == 'executed' diff --git a/utils/authz.py b/utils/authz.py deleted file mode 100644 index 086ca27..0000000 --- a/utils/authz.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Authorization helpers for role-based and arming-gated operations.""" - -from __future__ import annotations - -from functools import wraps -from typing import Any, Callable - -from flask import jsonify, session - -ROLE_LEVELS: dict[str, int] = { - 'viewer': 10, - 'analyst': 20, - 'operator': 30, - 'supervisor': 40, - 'admin': 50, -} - - -def current_username() -> str: - """Get current username from session.""" - return str(session.get('username') or 'anonymous') - - -def current_role() -> str: - """Get current role from session with safe default.""" - role = str(session.get('role') or 'viewer').strip().lower() - return role if role in ROLE_LEVELS else 'viewer' - - -def has_role(required_role: str) -> bool: - """Return True if current session role satisfies required role.""" - required = ROLE_LEVELS.get(required_role, ROLE_LEVELS['admin']) - actual = ROLE_LEVELS.get(current_role(), ROLE_LEVELS['viewer']) - return actual >= required - - -def require_role(required_role: str) -> Callable: - """Decorator enforcing minimum role.""" - - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args: Any, **kwargs: Any): - if not has_role(required_role): - return jsonify({ - 'status': 'error', - 'message': f'{required_role} role required', - 'required_role': required_role, - 'current_role': current_role(), - }), 403 - return func(*args, **kwargs) - - return wrapper - - return decorator - - -def require_armed(func: Callable) -> Callable: - """Decorator enforcing armed state for active actions.""" - - @wraps(func) - def wrapper(*args: Any, **kwargs: Any): - from utils.drone import get_drone_ops_service - - service = get_drone_ops_service() - policy = service.get_policy_state() - if not policy.get('armed'): - return jsonify({ - 'status': 'error', - 'message': 'Action plane is not armed', - 'policy': policy, - }), 403 - return func(*args, **kwargs) - - return wrapper diff --git a/utils/database.py b/utils/database.py index 373fa68..a87c54d 100644 --- a/utils/database.py +++ b/utils/database.py @@ -555,172 +555,6 @@ def init_db() -> None: VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1) ''') - # ===================================================================== - # Drone Ops / Professional Ops Tables - # ===================================================================== - - conn.execute(''' - CREATE TABLE IF NOT EXISTS drone_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - mode TEXT NOT NULL DEFAULT 'passive', - label TEXT, - operator TEXT, - metadata TEXT, - started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - stopped_at TIMESTAMP, - summary TEXT - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS drone_detections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER, - source TEXT NOT NULL, - identifier TEXT NOT NULL, - classification TEXT, - confidence REAL DEFAULT 0, - first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - payload_json TEXT, - remote_id_json TEXT, - FOREIGN KEY (session_id) REFERENCES drone_sessions(id) ON DELETE SET NULL, - UNIQUE(session_id, source, identifier) - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS drone_tracks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - detection_id INTEGER NOT NULL, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - lat REAL, - lon REAL, - altitude_m REAL, - speed_mps REAL, - heading_deg REAL, - quality REAL, - source TEXT, - FOREIGN KEY (detection_id) REFERENCES drone_detections(id) ON DELETE CASCADE - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS drone_correlations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - drone_identifier TEXT, - operator_identifier TEXT, - method TEXT, - confidence REAL, - evidence_json TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS drone_incidents ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - status TEXT DEFAULT 'open', - severity TEXT DEFAULT 'medium', - opened_by TEXT, - opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - closed_at TIMESTAMP, - summary TEXT, - metadata TEXT - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS drone_incident_artifacts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - incident_id INTEGER NOT NULL, - artifact_type TEXT NOT NULL, - artifact_ref TEXT NOT NULL, - added_by TEXT, - added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - metadata TEXT, - FOREIGN KEY (incident_id) REFERENCES drone_incidents(id) ON DELETE CASCADE - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS action_requests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - incident_id INTEGER NOT NULL, - action_type TEXT NOT NULL, - requested_by TEXT NOT NULL, - requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - status TEXT DEFAULT 'pending', - payload_json TEXT, - executed_at TIMESTAMP, - executed_by TEXT, - FOREIGN KEY (incident_id) REFERENCES drone_incidents(id) ON DELETE CASCADE - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS action_approvals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_id INTEGER NOT NULL, - approved_by TEXT NOT NULL, - approved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - decision TEXT NOT NULL, - notes TEXT, - FOREIGN KEY (request_id) REFERENCES action_requests(id) ON DELETE CASCADE - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS action_audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_id INTEGER, - event_type TEXT NOT NULL, - actor TEXT, - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - details_json TEXT, - FOREIGN KEY (request_id) REFERENCES action_requests(id) ON DELETE SET NULL - ) - ''') - - conn.execute(''' - CREATE TABLE IF NOT EXISTS evidence_manifests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - incident_id INTEGER NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - hash_algo TEXT DEFAULT 'sha256', - manifest_json TEXT NOT NULL, - signature TEXT, - created_by TEXT, - FOREIGN KEY (incident_id) REFERENCES drone_incidents(id) ON DELETE CASCADE - ) - ''') - - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_drone_detections_last_seen - ON drone_detections(last_seen, confidence) - ''') - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_drone_tracks_detection_time - ON drone_tracks(detection_id, timestamp) - ''') - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_drone_incidents_status - ON drone_incidents(status, opened_at) - ''') - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_action_requests_status - ON action_requests(status, requested_at) - ''') - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_action_approvals_request - ON action_approvals(request_id, approved_at) - ''') - conn.execute(''' - CREATE INDEX IF NOT EXISTS idx_evidence_manifests_incident - ON evidence_manifests(incident_id, created_at) - ''') - logger.info("Database initialized successfully") @@ -2469,806 +2303,3 @@ def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]: return True, 'Removed' -# ============================================================================= -# Drone Ops / Professional Ops Functions -# ============================================================================= - -def _decode_json(raw: str | None, default: Any = None) -> Any: - """Decode JSON safely with fallback default.""" - if raw is None or raw == '': - return default - try: - return json.loads(raw) - except (TypeError, json.JSONDecodeError): - return default - - -def create_drone_session( - mode: str = 'passive', - label: str | None = None, - operator: str | None = None, - metadata: dict | None = None, -) -> int: - """Create a Drone Ops session and return its ID.""" - with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO drone_sessions (mode, label, operator, metadata) - VALUES (?, ?, ?, ?) - ''', (mode, label, operator, json.dumps(metadata) if metadata else None)) - return int(cursor.lastrowid) - - -def stop_drone_session(session_id: int, summary: dict | None = None) -> bool: - """Stop an active Drone Ops session.""" - with get_db() as conn: - cursor = conn.execute(''' - UPDATE drone_sessions - SET stopped_at = CURRENT_TIMESTAMP, summary = ? - WHERE id = ? AND stopped_at IS NULL - ''', (json.dumps(summary) if summary else None, session_id)) - return cursor.rowcount > 0 - - -def get_drone_session(session_id: int) -> dict | None: - """Get Drone Ops session by ID.""" - with get_db() as conn: - row = conn.execute( - 'SELECT * FROM drone_sessions WHERE id = ?', - (session_id,), - ).fetchone() - if not row: - return None - return { - 'id': row['id'], - 'mode': row['mode'], - 'label': row['label'], - 'operator': row['operator'], - 'metadata': _decode_json(row['metadata'], {}), - 'started_at': row['started_at'], - 'stopped_at': row['stopped_at'], - 'summary': _decode_json(row['summary'], {}), - 'active': row['stopped_at'] is None, - } - - -def get_active_drone_session() -> dict | None: - """Return currently active Drone Ops session, if any.""" - with get_db() as conn: - row = conn.execute(''' - SELECT * FROM drone_sessions - WHERE stopped_at IS NULL - ORDER BY started_at DESC - LIMIT 1 - ''').fetchone() - if not row: - return None - return { - 'id': row['id'], - 'mode': row['mode'], - 'label': row['label'], - 'operator': row['operator'], - 'metadata': _decode_json(row['metadata'], {}), - 'started_at': row['started_at'], - 'stopped_at': row['stopped_at'], - 'summary': _decode_json(row['summary'], {}), - 'active': True, - } - - -def list_drone_sessions(limit: int = 50, active_only: bool = False) -> list[dict]: - """List Drone Ops sessions.""" - query = ''' - SELECT * FROM drone_sessions - ''' - params: list[Any] = [] - if active_only: - query += ' WHERE stopped_at IS NULL' - query += ' ORDER BY started_at DESC LIMIT ?' - params.append(limit) - - with get_db() as conn: - rows = conn.execute(query, params).fetchall() - return [ - { - 'id': row['id'], - 'mode': row['mode'], - 'label': row['label'], - 'operator': row['operator'], - 'metadata': _decode_json(row['metadata'], {}), - 'started_at': row['started_at'], - 'stopped_at': row['stopped_at'], - 'summary': _decode_json(row['summary'], {}), - 'active': row['stopped_at'] is None, - } - for row in rows - ] - - -def upsert_drone_detection( - session_id: int | None, - source: str, - identifier: str, - classification: str | None = None, - confidence: float = 0.0, - payload: dict | None = None, - remote_id: dict | None = None, -) -> int: - """Insert or update a Drone Ops detection, returning detection ID.""" - source = (source or '').strip().lower() - identifier = (identifier or '').strip() - if not source or not identifier: - raise ValueError('source and identifier are required') - - with get_db() as conn: - if session_id is None: - row = conn.execute(''' - SELECT id FROM drone_detections - WHERE session_id IS NULL AND source = ? AND identifier = ? - ''', (source, identifier)).fetchone() - else: - row = conn.execute(''' - SELECT id FROM drone_detections - WHERE session_id = ? AND source = ? AND identifier = ? - ''', (session_id, source, identifier)).fetchone() - - payload_json = json.dumps(payload) if payload is not None else None - remote_id_json = json.dumps(remote_id) if remote_id is not None else None - - if row: - detection_id = int(row['id']) - conn.execute(''' - UPDATE drone_detections - SET - classification = COALESCE(?, classification), - confidence = CASE WHEN ? > confidence THEN ? ELSE confidence END, - last_seen = CURRENT_TIMESTAMP, - payload_json = COALESCE(?, payload_json), - remote_id_json = COALESCE(?, remote_id_json) - WHERE id = ? - ''', ( - classification, - confidence, - confidence, - payload_json, - remote_id_json, - detection_id, - )) - return detection_id - - cursor = conn.execute(''' - INSERT INTO drone_detections - (session_id, source, identifier, classification, confidence, payload_json, remote_id_json) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ( - session_id, - source, - identifier, - classification, - confidence, - payload_json, - remote_id_json, - )) - return int(cursor.lastrowid) - - -def get_drone_detection(detection_id: int) -> dict | None: - """Get a Drone Ops detection by ID.""" - with get_db() as conn: - row = conn.execute( - 'SELECT * FROM drone_detections WHERE id = ?', - (detection_id,), - ).fetchone() - if not row: - return None - return { - 'id': row['id'], - 'session_id': row['session_id'], - 'source': row['source'], - 'identifier': row['identifier'], - 'classification': row['classification'], - 'confidence': float(row['confidence'] or 0.0), - 'first_seen': row['first_seen'], - 'last_seen': row['last_seen'], - 'payload': _decode_json(row['payload_json'], {}), - 'remote_id': _decode_json(row['remote_id_json'], {}), - } - - -def list_drone_detections( - session_id: int | None = None, - source: str | None = None, - min_confidence: float = 0.0, - limit: int = 200, -) -> list[dict]: - """List Drone Ops detections with optional filters.""" - conditions = ['confidence >= ?'] - params: list[Any] = [min_confidence] - - if session_id is not None: - conditions.append('session_id = ?') - params.append(session_id) - if source: - conditions.append('source = ?') - params.append(source.strip().lower()) - - where_clause = 'WHERE ' + ' AND '.join(conditions) - params.append(limit) - - with get_db() as conn: - rows = conn.execute(f''' - SELECT * FROM drone_detections - {where_clause} - ORDER BY last_seen DESC - LIMIT ? - ''', params).fetchall() - - return [ - { - 'id': row['id'], - 'session_id': row['session_id'], - 'source': row['source'], - 'identifier': row['identifier'], - 'classification': row['classification'], - 'confidence': float(row['confidence'] or 0.0), - 'first_seen': row['first_seen'], - 'last_seen': row['last_seen'], - 'payload': _decode_json(row['payload_json'], {}), - 'remote_id': _decode_json(row['remote_id_json'], {}), - } - for row in rows - ] - - -def add_drone_track( - detection_id: int, - lat: float | None = None, - lon: float | None = None, - altitude_m: float | None = None, - speed_mps: float | None = None, - heading_deg: float | None = None, - quality: float | None = None, - source: str | None = None, -) -> int: - """Add a track point for a detection.""" - with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO drone_tracks - (detection_id, lat, lon, altitude_m, speed_mps, heading_deg, quality, source) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', (detection_id, lat, lon, altitude_m, speed_mps, heading_deg, quality, source)) - return int(cursor.lastrowid) - - -def list_drone_tracks( - detection_id: int | None = None, - identifier: str | None = None, - limit: int = 1000, -) -> list[dict]: - """List track points by detection ID or detection identifier.""" - params: list[Any] = [] - where_clause = '' - join_clause = '' - - if detection_id is not None: - where_clause = 'WHERE t.detection_id = ?' - params.append(detection_id) - elif identifier: - join_clause = 'JOIN drone_detections d ON t.detection_id = d.id' - where_clause = 'WHERE d.identifier = ?' - params.append(identifier) - - params.append(limit) - - with get_db() as conn: - rows = conn.execute(f''' - SELECT t.* - FROM drone_tracks t - {join_clause} - {where_clause} - ORDER BY t.timestamp DESC - LIMIT ? - ''', params).fetchall() - - return [ - { - 'id': row['id'], - 'detection_id': row['detection_id'], - 'timestamp': row['timestamp'], - 'lat': row['lat'], - 'lon': row['lon'], - 'altitude_m': row['altitude_m'], - 'speed_mps': row['speed_mps'], - 'heading_deg': row['heading_deg'], - 'quality': row['quality'], - 'source': row['source'], - } - for row in rows - ] - - -def add_drone_correlation( - drone_identifier: str, - operator_identifier: str, - method: str, - confidence: float, - evidence: dict | None = None, -) -> int: - """Store a drone/operator correlation result.""" - with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO drone_correlations - (drone_identifier, operator_identifier, method, confidence, evidence_json) - VALUES (?, ?, ?, ?, ?) - ''', ( - drone_identifier, - operator_identifier, - method, - confidence, - json.dumps(evidence) if evidence else None, - )) - return int(cursor.lastrowid) - - -def list_drone_correlations( - drone_identifier: str | None = None, - min_confidence: float = 0.0, - limit: int = 200, -) -> list[dict]: - """List drone correlation records.""" - conditions = ['confidence >= ?'] - params: list[Any] = [min_confidence] - if drone_identifier: - conditions.append('drone_identifier = ?') - params.append(drone_identifier) - params.append(limit) - - with get_db() as conn: - rows = conn.execute(f''' - SELECT * FROM drone_correlations - WHERE {" AND ".join(conditions)} - ORDER BY created_at DESC - LIMIT ? - ''', params).fetchall() - - return [ - { - 'id': row['id'], - 'drone_identifier': row['drone_identifier'], - 'operator_identifier': row['operator_identifier'], - 'method': row['method'], - 'confidence': float(row['confidence'] or 0.0), - 'evidence': _decode_json(row['evidence_json'], {}), - 'created_at': row['created_at'], - } - for row in rows - ] - - -def create_drone_incident( - title: str, - severity: str = 'medium', - opened_by: str | None = None, - summary: str | None = None, - metadata: dict | None = None, -) -> int: - """Create a Drone Ops incident.""" - with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO drone_incidents - (title, severity, opened_by, summary, metadata) - VALUES (?, ?, ?, ?, ?) - ''', (title, severity, opened_by, summary, json.dumps(metadata) if metadata else None)) - return int(cursor.lastrowid) - - -def update_drone_incident( - incident_id: int, - status: str | None = None, - severity: str | None = None, - summary: str | None = None, - metadata: dict | None = None, -) -> bool: - """Update incident status/metadata.""" - updates = [] - params: list[Any] = [] - - if status is not None: - updates.append('status = ?') - params.append(status) - if status.lower() == 'closed': - updates.append('closed_at = CURRENT_TIMESTAMP') - elif status.lower() in {'open', 'active'}: - updates.append('closed_at = NULL') - if severity is not None: - updates.append('severity = ?') - params.append(severity) - if summary is not None: - updates.append('summary = ?') - params.append(summary) - if metadata is not None: - updates.append('metadata = ?') - params.append(json.dumps(metadata)) - - if not updates: - return False - - params.append(incident_id) - with get_db() as conn: - cursor = conn.execute( - f'UPDATE drone_incidents SET {", ".join(updates)} WHERE id = ?', - params, - ) - return cursor.rowcount > 0 - - -def add_drone_incident_artifact( - incident_id: int, - artifact_type: str, - artifact_ref: str, - added_by: str | None = None, - metadata: dict | None = None, -) -> int: - """Add an artifact reference to an incident.""" - with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO drone_incident_artifacts - (incident_id, artifact_type, artifact_ref, added_by, metadata) - VALUES (?, ?, ?, ?, ?) - ''', ( - incident_id, - artifact_type, - artifact_ref, - added_by, - json.dumps(metadata) if metadata else None, - )) - return int(cursor.lastrowid) - - -def get_drone_incident(incident_id: int) -> dict | None: - """Get an incident with linked artifacts and manifests.""" - with get_db() as conn: - row = conn.execute( - 'SELECT * FROM drone_incidents WHERE id = ?', - (incident_id,), - ).fetchone() - if not row: - return None - - artifacts_rows = conn.execute(''' - SELECT * FROM drone_incident_artifacts - WHERE incident_id = ? - ORDER BY added_at DESC - ''', (incident_id,)).fetchall() - - manifests_rows = conn.execute(''' - SELECT id, incident_id, created_at, hash_algo, signature, created_by, manifest_json - FROM evidence_manifests - WHERE incident_id = ? - ORDER BY created_at DESC - ''', (incident_id,)).fetchall() - - return { - 'id': row['id'], - 'title': row['title'], - 'status': row['status'], - 'severity': row['severity'], - 'opened_by': row['opened_by'], - 'opened_at': row['opened_at'], - 'closed_at': row['closed_at'], - 'summary': row['summary'], - 'metadata': _decode_json(row['metadata'], {}), - 'artifacts': [ - { - 'id': a['id'], - 'incident_id': a['incident_id'], - 'artifact_type': a['artifact_type'], - 'artifact_ref': a['artifact_ref'], - 'added_by': a['added_by'], - 'added_at': a['added_at'], - 'metadata': _decode_json(a['metadata'], {}), - } - for a in artifacts_rows - ], - 'manifests': [ - { - 'id': m['id'], - 'incident_id': m['incident_id'], - 'created_at': m['created_at'], - 'hash_algo': m['hash_algo'], - 'signature': m['signature'], - 'created_by': m['created_by'], - 'manifest': _decode_json(m['manifest_json'], {}), - } - for m in manifests_rows - ], - } - - -def list_drone_incidents(status: str | None = None, limit: int = 100) -> list[dict]: - """List incidents.""" - query = 'SELECT * FROM drone_incidents' - params: list[Any] = [] - if status: - query += ' WHERE status = ?' - params.append(status) - query += ' ORDER BY opened_at DESC LIMIT ?' - params.append(limit) - - with get_db() as conn: - rows = conn.execute(query, params).fetchall() - return [ - { - 'id': row['id'], - 'title': row['title'], - 'status': row['status'], - 'severity': row['severity'], - 'opened_by': row['opened_by'], - 'opened_at': row['opened_at'], - 'closed_at': row['closed_at'], - 'summary': row['summary'], - 'metadata': _decode_json(row['metadata'], {}), - } - for row in rows - ] - - -def create_action_request( - incident_id: int, - action_type: str, - requested_by: str, - payload: dict | None = None, -) -> int: - """Create an action request record.""" - with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO action_requests - (incident_id, action_type, requested_by, payload_json) - VALUES (?, ?, ?, ?) - ''', ( - incident_id, - action_type, - requested_by, - json.dumps(payload) if payload else None, - )) - return int(cursor.lastrowid) - - -def update_action_request_status( - request_id: int, - status: str, - executed_by: str | None = None, -) -> bool: - """Update action request status.""" - with get_db() as conn: - if status.lower() == 'executed': - cursor = conn.execute(''' - UPDATE action_requests - SET status = ?, executed_at = CURRENT_TIMESTAMP, executed_by = ? - WHERE id = ? - ''', (status, executed_by, request_id)) - else: - cursor = conn.execute( - 'UPDATE action_requests SET status = ? WHERE id = ?', - (status, request_id), - ) - return cursor.rowcount > 0 - - -def get_action_request(request_id: int) -> dict | None: - """Get action request with approval summary.""" - with get_db() as conn: - row = conn.execute( - 'SELECT * FROM action_requests WHERE id = ?', - (request_id,), - ).fetchone() - if not row: - return None - - approvals = conn.execute(''' - SELECT id, request_id, approved_by, approved_at, decision, notes - FROM action_approvals - WHERE request_id = ? - ORDER BY approved_at ASC - ''', (request_id,)).fetchall() - - return { - 'id': row['id'], - 'incident_id': row['incident_id'], - 'action_type': row['action_type'], - 'requested_by': row['requested_by'], - 'requested_at': row['requested_at'], - 'status': row['status'], - 'payload': _decode_json(row['payload_json'], {}), - 'executed_at': row['executed_at'], - 'executed_by': row['executed_by'], - 'approvals': [ - { - 'id': ap['id'], - 'request_id': ap['request_id'], - 'approved_by': ap['approved_by'], - 'approved_at': ap['approved_at'], - 'decision': ap['decision'], - 'notes': ap['notes'], - } - for ap in approvals - ], - } - - -def list_action_requests( - incident_id: int | None = None, - status: str | None = None, - limit: int = 100, -) -> list[dict]: - """List action requests with optional filtering.""" - conditions = [] - params: list[Any] = [] - if incident_id is not None: - conditions.append('incident_id = ?') - params.append(incident_id) - if status: - conditions.append('status = ?') - params.append(status) - where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else '' - params.append(limit) - - with get_db() as conn: - rows = conn.execute(f''' - SELECT * FROM action_requests - {where_clause} - ORDER BY requested_at DESC - LIMIT ? - ''', params).fetchall() - - return [ - { - 'id': row['id'], - 'incident_id': row['incident_id'], - 'action_type': row['action_type'], - 'requested_by': row['requested_by'], - 'requested_at': row['requested_at'], - 'status': row['status'], - 'payload': _decode_json(row['payload_json'], {}), - 'executed_at': row['executed_at'], - 'executed_by': row['executed_by'], - } - for row in rows - ] - - -def add_action_approval( - request_id: int, - approved_by: str, - decision: str = 'approved', - notes: str | None = None, -) -> int: - """Add an approval decision to an action request.""" - with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO action_approvals - (request_id, approved_by, decision, notes) - VALUES (?, ?, ?, ?) - ''', (request_id, approved_by, decision, notes)) - return int(cursor.lastrowid) - - -def add_action_audit_log( - request_id: int | None, - event_type: str, - actor: str | None = None, - details: dict | None = None, -) -> int: - """Append an action audit log event.""" - with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO action_audit_log - (request_id, event_type, actor, details_json) - VALUES (?, ?, ?, ?) - ''', ( - request_id, - event_type, - actor, - json.dumps(details) if details else None, - )) - return int(cursor.lastrowid) - - -def list_action_audit_logs( - request_id: int | None = None, - limit: int = 200, -) -> list[dict]: - """List action audit logs.""" - params: list[Any] = [] - where_clause = '' - if request_id is not None: - where_clause = 'WHERE request_id = ?' - params.append(request_id) - params.append(limit) - - with get_db() as conn: - rows = conn.execute(f''' - SELECT * FROM action_audit_log - {where_clause} - ORDER BY timestamp DESC - LIMIT ? - ''', params).fetchall() - - return [ - { - 'id': row['id'], - 'request_id': row['request_id'], - 'event_type': row['event_type'], - 'actor': row['actor'], - 'timestamp': row['timestamp'], - 'details': _decode_json(row['details_json'], {}), - } - for row in rows - ] - - -def create_evidence_manifest( - incident_id: int, - manifest: dict, - hash_algo: str = 'sha256', - signature: str | None = None, - created_by: str | None = None, -) -> int: - """Store evidence manifest metadata for an incident.""" - with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO evidence_manifests - (incident_id, hash_algo, manifest_json, signature, created_by) - VALUES (?, ?, ?, ?, ?) - ''', ( - incident_id, - hash_algo, - json.dumps(manifest), - signature, - created_by, - )) - return int(cursor.lastrowid) - - -def get_evidence_manifest(manifest_id: int) -> dict | None: - """Get an evidence manifest by ID.""" - with get_db() as conn: - row = conn.execute( - 'SELECT * FROM evidence_manifests WHERE id = ?', - (manifest_id,), - ).fetchone() - if not row: - return None - return { - 'id': row['id'], - 'incident_id': row['incident_id'], - 'created_at': row['created_at'], - 'hash_algo': row['hash_algo'], - 'manifest': _decode_json(row['manifest_json'], {}), - 'signature': row['signature'], - 'created_by': row['created_by'], - } - - -def list_evidence_manifests(incident_id: int, limit: int = 50) -> list[dict]: - """List manifests for an incident.""" - with get_db() as conn: - rows = conn.execute(''' - SELECT * FROM evidence_manifests - WHERE incident_id = ? - ORDER BY created_at DESC - LIMIT ? - ''', (incident_id, limit)).fetchall() - - return [ - { - 'id': row['id'], - 'incident_id': row['incident_id'], - 'created_at': row['created_at'], - 'hash_algo': row['hash_algo'], - 'manifest': _decode_json(row['manifest_json'], {}), - 'signature': row['signature'], - 'created_by': row['created_by'], - } - for row in rows - ] - diff --git a/utils/drone/__init__.py b/utils/drone/__init__.py deleted file mode 100644 index ea344fb..0000000 --- a/utils/drone/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Drone Ops utility package.""" - -from .service import DroneOpsService, get_drone_ops_service -from .remote_id import decode_remote_id_payload - -__all__ = [ - 'DroneOpsService', - 'get_drone_ops_service', - 'decode_remote_id_payload', -] diff --git a/utils/drone/detector.py b/utils/drone/detector.py deleted file mode 100644 index 3886bf0..0000000 --- a/utils/drone/detector.py +++ /dev/null @@ -1,305 +0,0 @@ -"""Heuristics for identifying drone-related emissions across WiFi/BLE/RF feeds.""" - -from __future__ import annotations - -import re -from typing import Any - -from utils.drone.remote_id import decode_remote_id_payload - -SSID_PATTERNS = [ - re.compile(r'(^|[-_\s])(dji|mavic|phantom|inspire|matrice|mini)([-_\s]|$)', re.IGNORECASE), - re.compile(r'(^|[-_\s])(parrot|anafi|bebop)([-_\s]|$)', re.IGNORECASE), - re.compile(r'(^|[-_\s])(autel|evo)([-_\s]|$)', re.IGNORECASE), - re.compile(r'(^|[-_\s])(skydio|yuneec)([-_\s]|$)', re.IGNORECASE), - re.compile(r'(^|[-_\s])(uas|uav|drone|rid|opendroneid)([-_\s]|$)', re.IGNORECASE), -] - -DRONE_OUI_PREFIXES = { - '60:60:1F': 'DJI', - '90:3A:E6': 'DJI', - '34:D2:62': 'DJI', - '90:3A:AF': 'DJI', - '00:12:1C': 'Parrot', - '90:03:B7': 'Parrot', - '48:1C:B9': 'Autel', - 'AC:89:95': 'Skydio', -} - -BT_NAME_PATTERNS = [ - re.compile(r'(dji|mavic|phantom|inspire|matrice|mini)', re.IGNORECASE), - re.compile(r'(parrot|anafi|bebop)', re.IGNORECASE), - re.compile(r'(autel|evo)', re.IGNORECASE), - re.compile(r'(skydio|yuneec)', re.IGNORECASE), - re.compile(r'(remote\s?id|opendroneid|uas|uav|drone)', re.IGNORECASE), -] - -REMOTE_ID_UUID_HINTS = {'fffa', 'faff', 'fffb'} -RF_FREQ_HINTS_MHZ = (315.0, 433.92, 868.0, 915.0, 1200.0, 2400.0, 5800.0) - - -def _normalize_mac(value: Any) -> str: - text = str(value or '').strip().upper().replace('-', ':') - if len(text) >= 8: - return text - return '' - - -def _extract_wifi_event(event: dict) -> dict | None: - if not isinstance(event, dict): - return None - if isinstance(event.get('network'), dict): - return event['network'] - if event.get('type') == 'network_update' and isinstance(event.get('network'), dict): - return event['network'] - if any(k in event for k in ('bssid', 'essid', 'ssid')): - return event - return None - - -def _extract_bt_event(event: dict) -> dict | None: - if not isinstance(event, dict): - return None - if isinstance(event.get('device'), dict): - return event['device'] - if any(k in event for k in ('device_id', 'address', 'name', 'manufacturer_name', 'service_uuids')): - return event - return None - - -def _extract_frequency_mhz(event: dict) -> float | None: - if not isinstance(event, dict): - return None - - candidates = [ - event.get('frequency_mhz'), - event.get('frequency'), - ] - - if 'frequency_hz' in event: - try: - candidates.append(float(event['frequency_hz']) / 1_000_000.0) - except (TypeError, ValueError): - pass - - for value in candidates: - try: - if value is None: - continue - f = float(value) - if f > 100000: # likely in Hz - f = f / 1_000_000.0 - if 1.0 <= f <= 7000.0: - return round(f, 6) - except (TypeError, ValueError): - continue - - text = str(event.get('text') or event.get('message') or '') - match = re.search(r'([0-9]{2,4}(?:\.[0-9]+)?)\s*MHz', text, flags=re.IGNORECASE) - if match: - try: - return float(match.group(1)) - except ValueError: - return None - - return None - - -def _closest_freq_delta(freq_mhz: float) -> float: - return min(abs(freq_mhz - hint) for hint in RF_FREQ_HINTS_MHZ) - - -def _maybe_track_from_remote_id(remote_id: dict, source: str) -> dict | None: - if not remote_id.get('detected'): - return None - lat = remote_id.get('lat') - lon = remote_id.get('lon') - if lat is None or lon is None: - return None - return { - 'lat': lat, - 'lon': lon, - 'altitude_m': remote_id.get('altitude_m'), - 'speed_mps': remote_id.get('speed_mps'), - 'heading_deg': remote_id.get('heading_deg'), - 'quality': remote_id.get('confidence', 0.0), - 'source': source, - } - - -def _detect_wifi(event: dict) -> list[dict]: - network = _extract_wifi_event(event) - if not network: - return [] - - bssid = _normalize_mac(network.get('bssid') or network.get('mac') or network.get('id')) - ssid = str(network.get('essid') or network.get('ssid') or network.get('display_name') or '').strip() - identifier = bssid or ssid - if not identifier: - return [] - - score = 0.0 - reasons: list[str] = [] - - if ssid: - for pattern in SSID_PATTERNS: - if pattern.search(ssid): - score += 0.45 - reasons.append('ssid_pattern') - break - - if bssid and len(bssid) >= 8: - prefix = bssid[:8] - if prefix in DRONE_OUI_PREFIXES: - score += 0.45 - reasons.append(f'known_oui:{DRONE_OUI_PREFIXES[prefix]}') - - remote_id = decode_remote_id_payload(network) - if remote_id.get('detected'): - score = max(score, 0.75) - reasons.append('remote_id_payload') - - if score < 0.5: - return [] - - confidence = min(1.0, round(score, 3)) - classification = 'wifi_drone_remote_id' if remote_id.get('detected') else 'wifi_drone_signature' - - return [{ - 'source': 'wifi', - 'identifier': identifier, - 'classification': classification, - 'confidence': confidence, - 'payload': { - 'network': network, - 'reasons': reasons, - 'brand_hint': DRONE_OUI_PREFIXES.get(bssid[:8]) if bssid else None, - }, - 'remote_id': remote_id if remote_id.get('detected') else None, - 'track': _maybe_track_from_remote_id(remote_id, 'wifi'), - }] - - -def _detect_bluetooth(event: dict) -> list[dict]: - device = _extract_bt_event(event) - if not device: - return [] - - address = _normalize_mac(device.get('address') or device.get('mac')) - device_id = str(device.get('device_id') or '').strip() - name = str(device.get('name') or '').strip() - manufacturer = str(device.get('manufacturer_name') or '').strip() - identifier = address or device_id or name - if not identifier: - return [] - - score = 0.0 - reasons: list[str] = [] - - haystack = f'{name} {manufacturer}'.strip() - if haystack: - for pattern in BT_NAME_PATTERNS: - if pattern.search(haystack): - score += 0.55 - reasons.append('name_or_vendor_pattern') - break - - uuids = device.get('service_uuids') or [] - for uuid in uuids: - if str(uuid).replace('-', '').lower()[-4:] in REMOTE_ID_UUID_HINTS: - score = max(score, 0.7) - reasons.append('remote_id_service_uuid') - break - - tracker = device.get('tracker') if isinstance(device.get('tracker'), dict) else {} - if tracker.get('is_tracker') and 'drone' in str(tracker.get('type') or '').lower(): - score = max(score, 0.7) - reasons.append('tracker_engine_drone_label') - - remote_id = decode_remote_id_payload(device) - if remote_id.get('detected'): - score = max(score, 0.75) - reasons.append('remote_id_payload') - - if score < 0.55: - return [] - - confidence = min(1.0, round(score, 3)) - classification = 'bluetooth_drone_remote_id' if remote_id.get('detected') else 'bluetooth_drone_signature' - - return [{ - 'source': 'bluetooth', - 'identifier': identifier, - 'classification': classification, - 'confidence': confidence, - 'payload': { - 'device': device, - 'reasons': reasons, - }, - 'remote_id': remote_id if remote_id.get('detected') else None, - 'track': _maybe_track_from_remote_id(remote_id, 'bluetooth'), - }] - - -def _detect_rf(event: dict) -> list[dict]: - if not isinstance(event, dict): - return [] - - freq_mhz = _extract_frequency_mhz(event) - if freq_mhz is None: - return [] - - delta = _closest_freq_delta(freq_mhz) - if delta > 35.0: - return [] - - score = max(0.5, 0.85 - (delta / 100.0)) - confidence = min(1.0, round(score, 3)) - - event_id = str(event.get('capture_id') or event.get('id') or f'{freq_mhz:.3f}MHz') - identifier = f'rf:{event_id}' - - payload = { - 'event': event, - 'frequency_mhz': freq_mhz, - 'delta_from_known_band_mhz': round(delta, 3), - 'known_bands_mhz': list(RF_FREQ_HINTS_MHZ), - } - - return [{ - 'source': 'rf', - 'identifier': identifier, - 'classification': 'rf_drone_link_activity', - 'confidence': confidence, - 'payload': payload, - 'remote_id': None, - 'track': None, - }] - - -def detect_from_event(mode: str, event: dict, event_type: str | None = None) -> list[dict]: - """Detect drone-relevant signals from a normalized mode event.""" - mode_lower = str(mode or '').lower() - - if mode_lower.startswith('wifi'): - return _detect_wifi(event) - if mode_lower.startswith('bluetooth') or mode_lower.startswith('bt'): - return _detect_bluetooth(event) - if mode_lower in {'subghz', 'listening_scanner', 'waterfall', 'listening'}: - return _detect_rf(event) - - # Opportunistic decode from any feed that carries explicit remote ID payloads. - remote_id = decode_remote_id_payload(event) - if remote_id.get('detected'): - identifier = str(remote_id.get('uas_id') or remote_id.get('operator_id') or 'remote_id') - return [{ - 'source': mode_lower or 'unknown', - 'identifier': identifier, - 'classification': 'remote_id_detected', - 'confidence': float(remote_id.get('confidence') or 0.6), - 'payload': {'event': event, 'event_type': event_type}, - 'remote_id': remote_id, - 'track': _maybe_track_from_remote_id(remote_id, mode_lower or 'unknown'), - }] - - return [] diff --git a/utils/drone/policy.py b/utils/drone/policy.py deleted file mode 100644 index b927bd0..0000000 --- a/utils/drone/policy.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Drone Ops policy helpers.""" - -from __future__ import annotations - - -def required_approvals_for_action(action_type: str) -> int: - """Return required approvals for a given action type.""" - action = (action_type or '').strip().lower() - if action.startswith('passive_'): - return 1 - return 2 diff --git a/utils/drone/remote_id.py b/utils/drone/remote_id.py deleted file mode 100644 index 47dc604..0000000 --- a/utils/drone/remote_id.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Remote ID payload normalization and lightweight decoding helpers.""" - -from __future__ import annotations - -import json -from typing import Any - - -DRONE_ID_KEYS = ('uas_id', 'drone_id', 'serial_number', 'serial', 'id', 'uasId') -OPERATOR_ID_KEYS = ('operator_id', 'pilot_id', 'operator', 'operatorId') -LAT_KEYS = ('lat', 'latitude') -LON_KEYS = ('lon', 'lng', 'longitude') -ALT_KEYS = ('alt', 'altitude', 'altitude_m', 'height') -SPEED_KEYS = ('speed', 'speed_mps', 'ground_speed') -HEADING_KEYS = ('heading', 'heading_deg', 'course') - - -def _get_nested(data: dict, *paths: str) -> Any: - for path in paths: - current: Any = data - valid = True - for part in path.split('.'): - if not isinstance(current, dict) or part not in current: - valid = False - break - current = current[part] - if valid: - return current - return None - - -def _coerce_float(value: Any) -> float | None: - try: - if value is None or value == '': - return None - return float(value) - except (TypeError, ValueError): - return None - - -def _pick(data: dict, keys: tuple[str, ...], nested_prefixes: tuple[str, ...] = ()) -> Any: - for key in keys: - if key in data: - return data.get(key) - for prefix in nested_prefixes: - for key in keys: - hit = _get_nested(data, f'{prefix}.{key}') - if hit is not None: - return hit - return None - - -def _normalize_input(payload: Any) -> tuple[dict, str]: - if isinstance(payload, dict): - return payload, 'dict' - - if isinstance(payload, bytes): - text = payload.decode('utf-8', errors='replace').strip() - else: - text = str(payload or '').strip() - - if not text: - return {}, 'empty' - - # JSON-first parsing. - try: - parsed = json.loads(text) - if isinstance(parsed, dict): - return parsed, 'json' - except json.JSONDecodeError: - pass - - # Keep opaque string payload available to caller. - return {'raw': text}, 'raw' - - -def decode_remote_id_payload(payload: Any) -> dict: - """Decode/normalize Remote ID-like payload into a common shape.""" - data, fmt = _normalize_input(payload) - - drone_id = _pick(data, DRONE_ID_KEYS, ('remote_id', 'message', 'uas')) - operator_id = _pick(data, OPERATOR_ID_KEYS, ('remote_id', 'message', 'operator')) - - lat = _coerce_float(_pick(data, LAT_KEYS, ('remote_id', 'message', 'position'))) - lon = _coerce_float(_pick(data, LON_KEYS, ('remote_id', 'message', 'position'))) - altitude_m = _coerce_float(_pick(data, ALT_KEYS, ('remote_id', 'message', 'position'))) - speed_mps = _coerce_float(_pick(data, SPEED_KEYS, ('remote_id', 'message', 'position'))) - heading_deg = _coerce_float(_pick(data, HEADING_KEYS, ('remote_id', 'message', 'position'))) - - confidence = 0.0 - if drone_id: - confidence += 0.35 - if lat is not None and lon is not None: - confidence += 0.35 - if altitude_m is not None: - confidence += 0.15 - if operator_id: - confidence += 0.15 - confidence = min(1.0, round(confidence, 3)) - - detected = bool(drone_id or (lat is not None and lon is not None and confidence >= 0.35)) - - normalized = { - 'detected': detected, - 'source_format': fmt, - 'uas_id': str(drone_id).strip() if drone_id else None, - 'operator_id': str(operator_id).strip() if operator_id else None, - 'lat': lat, - 'lon': lon, - 'altitude_m': altitude_m, - 'speed_mps': speed_mps, - 'heading_deg': heading_deg, - 'confidence': confidence, - 'raw': data, - } - - # Remove heavy raw payload if we successfully extracted structure. - if detected and isinstance(data, dict) and len(data) > 0: - normalized['raw'] = data - - return normalized diff --git a/utils/drone/service.py b/utils/drone/service.py deleted file mode 100644 index d24bff4..0000000 --- a/utils/drone/service.py +++ /dev/null @@ -1,621 +0,0 @@ -"""Stateful Drone Ops service: ingestion, policy, incidents, actions, and evidence.""" - -from __future__ import annotations - -import hashlib -import json -import queue -import threading -import time -from datetime import datetime, timezone -from typing import Any, Generator - -import app as app_module - -from utils.correlation import get_correlations as get_wifi_bt_correlations -from utils.database import ( - add_action_approval, - add_action_audit_log, - add_drone_correlation, - add_drone_incident_artifact, - add_drone_track, - create_action_request, - create_drone_incident, - create_drone_session, - create_evidence_manifest, - get_action_request, - get_active_drone_session, - get_drone_detection, - get_drone_incident, - get_drone_session, - get_evidence_manifest, - list_action_audit_logs, - list_action_requests, - list_drone_correlations, - list_drone_detections, - list_drone_incidents, - list_drone_sessions, - list_drone_tracks, - list_evidence_manifests, - stop_drone_session, - update_action_request_status, - update_drone_incident, - upsert_drone_detection, -) -from utils.drone.detector import detect_from_event -from utils.drone.remote_id import decode_remote_id_payload -from utils.trilateration import estimate_location_from_observations - - -class DroneOpsService: - """Orchestrates Drone Ops data and policy controls.""" - - def __init__(self) -> None: - self._subscribers: set[queue.Queue] = set() - self._subs_lock = threading.Lock() - - self._policy_lock = threading.Lock() - self._armed_until_ts: float | None = None - self._armed_by: str | None = None - self._arm_reason: str | None = None - self._arm_incident_id: int | None = None - - # ------------------------------------------------------------------ - # Streaming - # ------------------------------------------------------------------ - - @staticmethod - def _utc_now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - def _emit(self, event_type: str, payload: dict) -> None: - envelope = { - 'type': event_type, - 'timestamp': self._utc_now_iso(), - 'payload': payload, - } - with self._subs_lock: - subscribers = tuple(self._subscribers) - - for sub in subscribers: - try: - sub.put_nowait(envelope) - except queue.Full: - try: - sub.get_nowait() - sub.put_nowait(envelope) - except (queue.Empty, queue.Full): - continue - - def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]: - """Yield Drone Ops events for SSE streaming.""" - client_queue: queue.Queue = queue.Queue(maxsize=500) - - with self._subs_lock: - self._subscribers.add(client_queue) - - try: - while True: - try: - yield client_queue.get(timeout=timeout) - except queue.Empty: - yield {'type': 'keepalive', 'timestamp': self._utc_now_iso(), 'payload': {}} - finally: - with self._subs_lock: - self._subscribers.discard(client_queue) - - # ------------------------------------------------------------------ - # Policy / arming - # ------------------------------------------------------------------ - - def _policy_state_locked(self) -> dict: - armed = self._armed_until_ts is not None and time.time() < self._armed_until_ts - if not armed: - self._armed_until_ts = None - self._armed_by = None - self._arm_reason = None - self._arm_incident_id = None - - return { - 'armed': armed, - 'armed_by': self._armed_by, - 'arm_reason': self._arm_reason, - 'arm_incident_id': self._arm_incident_id, - 'armed_until': datetime.fromtimestamp(self._armed_until_ts, tz=timezone.utc).isoformat() if self._armed_until_ts else None, - 'required_approvals_default': 2, - } - - def get_policy_state(self) -> dict: - """Get current policy and arming state.""" - with self._policy_lock: - return self._policy_state_locked() - - def arm_actions( - self, - actor: str, - reason: str, - incident_id: int, - duration_seconds: int = 900, - ) -> dict: - """Arm action plane for a bounded duration.""" - duration_seconds = max(60, min(7200, int(duration_seconds or 900))) - with self._policy_lock: - self._armed_until_ts = time.time() + duration_seconds - self._armed_by = actor - self._arm_reason = reason - self._arm_incident_id = incident_id - state = self._policy_state_locked() - - self._emit('policy_armed', {'actor': actor, 'reason': reason, 'incident_id': incident_id, 'state': state}) - return state - - def disarm_actions(self, actor: str, reason: str | None = None) -> dict: - """Disarm action plane.""" - with self._policy_lock: - self._armed_until_ts = None - self._armed_by = None - self._arm_reason = None - self._arm_incident_id = None - state = self._policy_state_locked() - - self._emit('policy_disarmed', {'actor': actor, 'reason': reason, 'state': state}) - return state - - @staticmethod - def required_approvals(action_type: str) -> int: - """Compute required approvals for an action type.""" - action = (action_type or '').strip().lower() - if action.startswith('passive_'): - return 1 - return 2 - - # ------------------------------------------------------------------ - # Sessions and detections - # ------------------------------------------------------------------ - - def start_session( - self, - mode: str, - label: str | None, - operator: str, - metadata: dict | None = None, - ) -> dict: - """Start a Drone Ops session.""" - active = get_active_drone_session() - if active: - return active - - session_id = create_drone_session( - mode=mode or 'passive', - label=label, - operator=operator, - metadata=metadata, - ) - session = get_drone_session(session_id) - if session: - self._emit('session_started', {'session': session}) - return session or {} - - def stop_session( - self, - operator: str, - session_id: int | None = None, - summary: dict | None = None, - ) -> dict | None: - """Stop a Drone Ops session.""" - active = get_active_drone_session() - target_id = session_id or (active['id'] if active else None) - if not target_id: - return None - - if summary is None: - summary = { - 'operator': operator, - 'stopped_at': self._utc_now_iso(), - 'detections': len(list_drone_detections(session_id=target_id, limit=1000)), - } - - stop_drone_session(target_id, summary=summary) - session = get_drone_session(target_id) - if session: - self._emit('session_stopped', {'session': session}) - return session - - def get_status(self) -> dict: - """Get full Drone Ops status payload.""" - return { - 'status': 'success', - 'active_session': get_active_drone_session(), - 'policy': self.get_policy_state(), - 'counts': { - 'detections': len(list_drone_detections(limit=1000)), - 'incidents_open': len(list_drone_incidents(status='open', limit=1000)), - 'actions_pending': len(list_action_requests(status='pending', limit=1000)), - }, - } - - def ingest_event(self, mode: str, event: dict, event_type: str | None = None) -> None: - """Ingest cross-mode event and produce Drone Ops detections.""" - try: - detections = detect_from_event(mode, event, event_type) - except Exception: - return - - if not detections: - return - - active = get_active_drone_session() - session_id = active['id'] if active else None - - for detection in detections: - try: - detection_id = upsert_drone_detection( - session_id=session_id, - source=detection['source'], - identifier=detection['identifier'], - classification=detection.get('classification'), - confidence=float(detection.get('confidence') or 0.0), - payload=detection.get('payload') or {}, - remote_id=detection.get('remote_id') or None, - ) - row = get_drone_detection(detection_id) - - track = detection.get('track') or {} - if row and track and track.get('lat') is not None and track.get('lon') is not None: - add_drone_track( - detection_id=row['id'], - lat=track.get('lat'), - lon=track.get('lon'), - altitude_m=track.get('altitude_m'), - speed_mps=track.get('speed_mps'), - heading_deg=track.get('heading_deg'), - quality=track.get('quality'), - source=track.get('source') or detection.get('source'), - ) - - remote_id = detection.get('remote_id') or {} - uas_id = remote_id.get('uas_id') - operator_id = remote_id.get('operator_id') - if uas_id and operator_id: - add_drone_correlation( - drone_identifier=str(uas_id), - operator_identifier=str(operator_id), - method='remote_id_binding', - confidence=float(remote_id.get('confidence') or 0.8), - evidence={ - 'source': detection.get('source'), - 'event_type': event_type, - 'detection_id': row['id'] if row else None, - }, - ) - - if row: - self._emit('detection', { - 'mode': mode, - 'event_type': event_type, - 'detection': row, - }) - except Exception: - continue - - def decode_remote_id(self, payload: Any) -> dict: - """Decode an explicit Remote ID payload.""" - decoded = decode_remote_id_payload(payload) - self._emit('remote_id_decoded', {'decoded': decoded}) - return decoded - - # ------------------------------------------------------------------ - # Queries - # ------------------------------------------------------------------ - - def get_detections( - self, - session_id: int | None = None, - source: str | None = None, - min_confidence: float = 0.0, - limit: int = 200, - ) -> list[dict]: - return list_drone_detections( - session_id=session_id, - source=source, - min_confidence=min_confidence, - limit=limit, - ) - - def get_tracks( - self, - detection_id: int | None = None, - identifier: str | None = None, - limit: int = 1000, - ) -> list[dict]: - return list_drone_tracks( - detection_id=detection_id, - identifier=identifier, - limit=limit, - ) - - def estimate_geolocation(self, observations: list[dict], environment: str = 'outdoor') -> dict | None: - """Estimate location from observations.""" - return estimate_location_from_observations(observations, environment=environment) - - def refresh_correlations(self, min_confidence: float = 0.6) -> list[dict]: - """Refresh and persist likely drone/operator correlations from WiFi<->BT pairs.""" - wifi_devices = dict(app_module.wifi_networks) - wifi_devices.update(dict(app_module.wifi_clients)) - bt_devices = dict(app_module.bt_devices) - - pairs = get_wifi_bt_correlations( - wifi_devices=wifi_devices, - bt_devices=bt_devices, - min_confidence=min_confidence, - include_historical=True, - ) - - detections = list_drone_detections(min_confidence=0.5, limit=1000) - known_ids = {d['identifier'].upper() for d in detections} - - for pair in pairs: - wifi_mac = str(pair.get('wifi_mac') or '').upper() - bt_mac = str(pair.get('bt_mac') or '').upper() - if wifi_mac in known_ids or bt_mac in known_ids: - add_drone_correlation( - drone_identifier=wifi_mac if wifi_mac in known_ids else bt_mac, - operator_identifier=bt_mac if wifi_mac in known_ids else wifi_mac, - method='wifi_bt_correlation', - confidence=float(pair.get('confidence') or 0.0), - evidence=pair, - ) - - return list_drone_correlations(min_confidence=min_confidence, limit=200) - - # ------------------------------------------------------------------ - # Incidents and artifacts - # ------------------------------------------------------------------ - - def create_incident( - self, - title: str, - severity: str, - opened_by: str, - summary: str | None, - metadata: dict | None, - ) -> dict: - incident_id = create_drone_incident( - title=title, - severity=severity, - opened_by=opened_by, - summary=summary, - metadata=metadata, - ) - incident = get_drone_incident(incident_id) or {'id': incident_id} - self._emit('incident_created', {'incident': incident}) - return incident - - def update_incident( - self, - incident_id: int, - status: str | None = None, - severity: str | None = None, - summary: str | None = None, - metadata: dict | None = None, - ) -> dict | None: - update_drone_incident( - incident_id=incident_id, - status=status, - severity=severity, - summary=summary, - metadata=metadata, - ) - incident = get_drone_incident(incident_id) - if incident: - self._emit('incident_updated', {'incident': incident}) - return incident - - def add_incident_artifact( - self, - incident_id: int, - artifact_type: str, - artifact_ref: str, - added_by: str, - metadata: dict | None = None, - ) -> dict: - artifact_id = add_drone_incident_artifact( - incident_id=incident_id, - artifact_type=artifact_type, - artifact_ref=artifact_ref, - added_by=added_by, - metadata=metadata, - ) - artifact = { - 'id': artifact_id, - 'incident_id': incident_id, - 'artifact_type': artifact_type, - 'artifact_ref': artifact_ref, - 'added_by': added_by, - 'metadata': metadata or {}, - } - self._emit('incident_artifact_added', {'artifact': artifact}) - return artifact - - # ------------------------------------------------------------------ - # Actions and approvals - # ------------------------------------------------------------------ - - def request_action( - self, - incident_id: int, - action_type: str, - requested_by: str, - payload: dict | None, - ) -> dict | None: - request_id = create_action_request( - incident_id=incident_id, - action_type=action_type, - requested_by=requested_by, - payload=payload, - ) - add_action_audit_log( - request_id=request_id, - event_type='requested', - actor=requested_by, - details={'payload': payload or {}}, - ) - req = get_action_request(request_id) - if req: - req['required_approvals'] = self.required_approvals(req['action_type']) - self._emit('action_requested', {'request': req}) - return req - - def approve_action( - self, - request_id: int, - approver: str, - decision: str = 'approved', - notes: str | None = None, - ) -> dict | None: - req = get_action_request(request_id) - if not req: - return None - - approvals = req.get('approvals', []) - if any((a.get('approved_by') or '').lower() == approver.lower() for a in approvals): - return req - - add_action_approval(request_id=request_id, approved_by=approver, decision=decision, notes=notes) - add_action_audit_log( - request_id=request_id, - event_type='approval', - actor=approver, - details={'decision': decision, 'notes': notes}, - ) - - req = get_action_request(request_id) - if not req: - return None - - approvals = req.get('approvals', []) - approved_count = len([a for a in approvals if str(a.get('decision')).lower() == 'approved']) - required = self.required_approvals(req['action_type']) - - if decision.lower() == 'rejected': - update_action_request_status(request_id, status='rejected') - elif approved_count >= required and req.get('status') not in {'executed', 'rejected'}: - update_action_request_status(request_id, status='approved') - - req = get_action_request(request_id) - if req: - req['required_approvals'] = required - req['approved_count'] = approved_count - self._emit('action_approved', {'request': req}) - return req - - def execute_action(self, request_id: int, actor: str) -> tuple[dict | None, str | None]: - """Execute an approved action request (policy-gated).""" - req = get_action_request(request_id) - if not req: - return None, 'Action request not found' - - policy = self.get_policy_state() - if not policy.get('armed'): - return None, 'Action plane is not armed' - - approvals = req.get('approvals', []) - approved_count = len([a for a in approvals if str(a.get('decision')).lower() == 'approved']) - required = self.required_approvals(req['action_type']) - - if approved_count < required: - return None, f'Insufficient approvals ({approved_count}/{required})' - - update_action_request_status(request_id, status='executed', executed_by=actor) - add_action_audit_log( - request_id=request_id, - event_type='executed', - actor=actor, - details={ - 'dispatch': 'framework', - 'note': 'Execution recorded. Attach route-specific handlers per action_type.', - }, - ) - - req = get_action_request(request_id) - if req: - req['required_approvals'] = required - req['approved_count'] = approved_count - self._emit('action_executed', {'request': req}) - return req, None - - # ------------------------------------------------------------------ - # Evidence and manifests - # ------------------------------------------------------------------ - - def generate_evidence_manifest( - self, - incident_id: int, - created_by: str, - signature: str | None = None, - ) -> dict | None: - """Build and persist an evidence manifest for an incident.""" - incident = get_drone_incident(incident_id) - if not incident: - return None - - action_requests = list_action_requests(incident_id=incident_id, limit=1000) - request_ids = [r['id'] for r in action_requests] - action_audit: list[dict] = [] - for request_id in request_ids: - action_audit.extend(list_action_audit_logs(request_id=request_id, limit=500)) - - manifest_core = { - 'generated_at': self._utc_now_iso(), - 'incident': { - 'id': incident['id'], - 'title': incident['title'], - 'status': incident['status'], - 'severity': incident['severity'], - 'opened_at': incident['opened_at'], - 'closed_at': incident['closed_at'], - }, - 'artifact_count': len(incident.get('artifacts', [])), - 'action_request_count': len(action_requests), - 'audit_event_count': len(action_audit), - 'artifacts': incident.get('artifacts', []), - 'action_requests': action_requests, - 'action_audit': action_audit, - } - - canonical = json.dumps(manifest_core, sort_keys=True, separators=(',', ':')) - sha256_hex = hashlib.sha256(canonical.encode('utf-8')).hexdigest() - - manifest = { - **manifest_core, - 'integrity': { - 'algorithm': 'sha256', - 'digest': sha256_hex, - }, - } - - manifest_id = create_evidence_manifest( - incident_id=incident_id, - manifest=manifest, - hash_algo='sha256', - signature=signature, - created_by=created_by, - ) - - stored = get_evidence_manifest(manifest_id) - if stored: - self._emit('evidence_manifest_created', {'manifest': stored}) - return stored - - -_drone_service: DroneOpsService | None = None -_drone_service_lock = threading.Lock() - - -def get_drone_ops_service() -> DroneOpsService: - """Get global Drone Ops service singleton.""" - global _drone_service - with _drone_service_lock: - if _drone_service is None: - _drone_service = DroneOpsService() - return _drone_service diff --git a/utils/event_pipeline.py b/utils/event_pipeline.py index 73aa5fd..35fcf53 100644 --- a/utils/event_pipeline.py +++ b/utils/event_pipeline.py @@ -54,14 +54,6 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) - # Alert failures should never break streaming pass - try: - from utils.drone import get_drone_ops_service - get_drone_ops_service().ingest_event(mode, event, event_type) - except Exception: - # Drone ingest should never break mode streaming - pass - - def _extract_device_id(event: dict) -> str | None: for field in DEVICE_ID_FIELDS: value = event.get(field) From c0221ba53dccfc7525392dfdef2040cc3ac6a82d Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 20 Feb 2026 17:18:15 +0000 Subject: [PATCH 03/14] Fix manual TLE parsing for pasted multiline input --- templates/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/index.html b/templates/index.html index abe7b92..ade06cc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10352,7 +10352,7 @@ return; } - const lines = tleText.split('\\n').map(l => l.trim()).filter(l => l); + const lines = tleText.split(/\r?\n/).map(l => l.trim()).filter(l => l); const toAdd = []; for (let i = 0; i < lines.length; i += 3) { From c3bf30b49c6b705be134a4ef9ecb1f58d756c4f6 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 20 Feb 2026 17:35:57 +0000 Subject: [PATCH 04/14] Fix BT Locate startup/map rendering and CelesTrak import reliability --- routes/bt_locate.py | 29 +++++-- routes/satellite.py | 87 ++++++++++++++------- static/js/modes/bt_locate.js | 147 +++++++++++++++++++++++++++-------- templates/index.html | 48 ++++++++---- tests/test_bt_locate.py | 49 ++++++++---- utils/bt_locate.py | 112 ++++++++++++++++---------- 6 files changed, 331 insertions(+), 141 deletions(-) diff --git a/routes/bt_locate.py b/routes/bt_locate.py index 11f1aa3..85bab64 100644 --- a/routes/bt_locate.py +++ b/routes/bt_locate.py @@ -109,14 +109,27 @@ def start_session(): f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})" ) - session = start_locate_session( - target, environment, custom_exponent, fallback_lat, fallback_lon - ) - - return jsonify({ - 'status': 'started', - 'session': session.get_status(), - }) + 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 jsonify({ + 'status': 'error', + '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 jsonify({ + 'status': 'error', + 'error': 'Failed to start locate session', + }), 500 + + return jsonify({ + 'status': 'started', + 'session': session.get_status(), + }) @bt_locate_bp.route('/stop', methods=['POST']) diff --git a/routes/satellite.py b/routes/satellite.py index 9134b54..f9fb3ca 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -584,40 +584,67 @@ def list_tracked_satellites(): return jsonify({'status': 'success', 'satellites': sats}) -@satellite_bp.route('/tracked', methods=['POST']) -def add_tracked_satellites_endpoint(): - """Add one or more tracked satellites.""" - global _tle_cache - data = request.json - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - # Accept a single satellite dict or a list - sat_list = data if isinstance(data, list) else [data] - - added = 0 - for sat in sat_list: - norad_id = str(sat.get('norad_id', sat.get('norad', ''))) - name = sat.get('name', '') - if not norad_id or not name: - continue +@satellite_bp.route('/tracked', methods=['POST']) +def add_tracked_satellites_endpoint(): + """Add one or more tracked satellites.""" + global _tle_cache + data = request.get_json(silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # Accept a single satellite dict or a list + sat_list = data if isinstance(data, list) else [data] + + normalized: list[dict] = [] + for sat in sat_list: + norad_id = str(sat.get('norad_id', sat.get('norad', ''))) + name = sat.get('name', '') + if not norad_id or not name: + continue tle1 = sat.get('tle_line1', sat.get('tle1')) tle2 = sat.get('tle_line2', sat.get('tle2')) enabled = sat.get('enabled', True) - if add_tracked_satellite(norad_id, name, tle1, tle2, enabled): - added += 1 - - # Also inject into TLE cache if we have TLE data - if tle1 and tle2: - cache_key = name.replace(' ', '-').upper() - _tle_cache[cache_key] = (name, tle1, tle2) - - return jsonify({ - 'status': 'success', - 'added': added, - 'satellites': get_tracked_satellites(), - }) + normalized.append({ + 'norad_id': norad_id, + 'name': name, + 'tle_line1': tle1, + 'tle_line2': tle2, + 'enabled': bool(enabled), + 'builtin': False, + }) + + # Also inject into TLE cache if we have TLE data + if tle1 and tle2: + cache_key = name.replace(' ', '-').upper() + _tle_cache[cache_key] = (name, tle1, tle2) + + # Single inserts preserve previous behavior; list inserts use DB-level bulk path. + if len(normalized) == 1: + sat = normalized[0] + added = 1 if add_tracked_satellite( + sat['norad_id'], + sat['name'], + sat.get('tle_line1'), + sat.get('tle_line2'), + sat.get('enabled', True), + sat.get('builtin', False), + ) else 0 + else: + added = bulk_add_tracked_satellites(normalized) + + response_payload = { + 'status': 'success', + 'added': added, + 'processed': len(normalized), + } + + # Returning all tracked satellites for very large imports can stall the UI. + include_satellites = request.args.get('include_satellites', '').lower() == 'true' + if include_satellites or len(normalized) <= 32: + response_payload['satellites'] = get_tracked_satellites() + + return jsonify(response_payload) @satellite_bp.route('/tracked/', methods=['PUT']) diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index eb8a802..2df6e7e 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -38,6 +38,7 @@ const BtLocate = (function() { let lastRenderedDetectionKey = null; let pendingHeatSync = false; let mapStabilizeTimer = null; + let modeActive = false; const MAX_HEAT_POINTS = 1200; const MAX_TRAIL_POINTS = 1200; @@ -85,6 +86,7 @@ const BtLocate = (function() { } function init() { + modeActive = true; loadOverlayPreferences(); syncOverlayControls(); @@ -92,7 +94,7 @@ const BtLocate = (function() { // Re-invalidate map on re-entry and ensure tiles are present if (map) { setTimeout(() => { - safeInvalidateMap(); + safeInvalidateMap(true); // Re-apply user's tile layer if tiles were lost let hasTiles = false; map.eachLayer(layer => { @@ -142,7 +144,7 @@ const BtLocate = (function() { flushPendingHeatSync(); }); setTimeout(() => { - safeInvalidateMap(); + safeInvalidateMap(true); flushPendingHeatSync(); }, 100); scheduleMapStabilization(); @@ -158,10 +160,10 @@ const BtLocate = (function() { initialized = true; } - function checkStatus() { - fetch('/bt_locate/status') - .then(r => r.json()) - .then(data => { + function checkStatus() { + fetch('/bt_locate/status') + .then(r => r.json()) + .then(data => { if (data.active) { sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now(); showActiveUI(); @@ -171,12 +173,22 @@ const BtLocate = (function() { } }) .catch(() => {}); - } - - function start() { - const mac = document.getElementById('btLocateMac')?.value.trim(); - const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); - const irk = document.getElementById('btLocateIrk')?.value.trim(); + } + + function normalizeMacInput(value) { + const raw = (value || '').trim().toUpperCase().replace(/-/g, ':'); + if (!raw) return ''; + const compact = raw.replace(/[^0-9A-F]/g, ''); + if (compact.length === 12) { + return compact.match(/.{1,2}/g).join(':'); + } + return raw; + } + + function start() { + const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value); + const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); + const irk = document.getElementById('btLocateIrk')?.value.trim(); const body = { environment: currentEnvironment }; if (mac) body.mac_address = mac; @@ -205,13 +217,25 @@ const BtLocate = (function() { return; } - fetch('/bt_locate/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - .then(r => r.json()) - .then(data => { + fetch('/bt_locate/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + .then(async (r) => { + let data = null; + try { + data = await r.json(); + } catch (_) { + data = {}; + } + if (!r.ok || data.status !== 'started') { + const message = data.error || data.message || ('HTTP ' + r.status); + throw new Error(message); + } + return data; + }) + .then(data => { if (data.status === 'started') { sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now(); showActiveUI(); @@ -224,8 +248,12 @@ const BtLocate = (function() { restoreTrail(); } }) - .catch(err => console.error('[BtLocate] Start error:', err)); - } + .catch(err => { + console.error('[BtLocate] Start error:', err); + alert('BT Locate failed to start: ' + (err?.message || 'Unknown error')); + showIdleUI(); + }); + } function stop() { fetch('/bt_locate/stop', { method: 'POST' }) @@ -888,7 +916,10 @@ const BtLocate = (function() { if (!map) return; ensureHeatLayer(); if (!heatLayer) return; - if (!isMapContainerVisible()) { + if (!modeActive || !isMapContainerVisible()) { + if (map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } pendingHeatSync = true; return; } @@ -918,6 +949,40 @@ const BtLocate = (function() { } } + function setActiveMode(active) { + modeActive = !!active; + if (!map) return; + + if (!modeActive) { + stopMapStabilization(); + if (heatLayer && map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } + pendingHeatSync = true; + return; + } + + setTimeout(() => { + if (!modeActive) return; + safeInvalidateMap(true); + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => { + if (!modeActive) return; + safeInvalidateMap(true); + window.requestAnimationFrame(() => { + if (!modeActive) return; + safeInvalidateMap(true); + }); + }); + } + syncHeatLayer(); + syncMovementLayer(); + syncStrongestMarker(); + updateConfidenceLayer(); + scheduleMapStabilization(14); + }, 80); + } + function isMapRenderable() { if (!map || !isMapContainerVisible()) return false; if (typeof map.getSize === 'function') { @@ -927,9 +992,26 @@ const BtLocate = (function() { return true; } - function safeInvalidateMap() { + function refreshBaseTiles() { + if (!map || typeof L === 'undefined' || typeof map.eachLayer !== 'function') return; + map.eachLayer((layer) => { + if (layer instanceof L.TileLayer && typeof layer.redraw === 'function') { + try { + layer.redraw(); + } catch (_) {} + } + }); + } + + function safeInvalidateMap(forceRecenter = false) { if (!map || !isMapContainerVisible()) return false; - map.invalidateSize({ pan: false, animate: false }); + map.invalidateSize({ pan: !!forceRecenter, animate: false }); + if (forceRecenter) { + const center = map.getCenter(); + const zoom = map.getZoom(); + map.setView(center, zoom, { animate: false }); + } + refreshBaseTiles(); return true; } @@ -950,7 +1032,7 @@ const BtLocate = (function() { stopMapStabilization(); return; } - if (safeInvalidateMap()) { + if (safeInvalidateMap(true)) { flushPendingHeatSync(); syncMovementLayer(); syncStrongestMarker(); @@ -1624,7 +1706,7 @@ const BtLocate = (function() { } function invalidateMap() { - if (safeInvalidateMap()) { + if (safeInvalidateMap(true)) { flushPendingHeatSync(); syncMovementLayer(); syncStrongestMarker(); @@ -1633,10 +1715,11 @@ const BtLocate = (function() { scheduleMapStabilization(8); } - return { - init, - start, - stop, + return { + init, + setActiveMode, + start, + stop, handoff, clearHandoff, setEnvironment, @@ -1651,4 +1734,6 @@ const BtLocate = (function() { invalidateMap, fetchPairedIrks, }; -})(); +})(); + +window.BtLocate = BtLocate; diff --git a/templates/index.html b/templates/index.html index ade06cc..a72830c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4004,6 +4004,11 @@ if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none'; if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none'; + // Prevent Leaflet heatmap redraws on hidden BT Locate map containers. + if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) { + BtLocate.setActiveMode(mode === 'bt_locate'); + } + // Hide sidebar by default for Meshtastic mode, show for others const mainContent = document.querySelector('.main-content'); if (mainContent) { @@ -10403,7 +10408,7 @@ fetch('/satellite/celestrak/' + category) .then(r => r.json()) - .then(data => { + .then(async data => { if (data.status === 'success' && data.satellites) { const toAdd = data.satellites .filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad))) @@ -10420,27 +10425,36 @@ return; } - fetch('/satellite/tracked', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(toAdd) - }) - .then(r => r.json()) - .then(result => { - if (result.status === 'success') { - _loadSatellitesFromAPI(); - status.innerHTML = `Added ${result.added} satellites (${data.satellites.length} total in category)`; + const batchSize = 250; + let addedTotal = 0; + + for (let i = 0; i < toAdd.length; i += batchSize) { + const batch = toAdd.slice(i, i + batchSize); + const completed = Math.min(i + batch.length, toAdd.length); + status.innerHTML = `Importing ${completed}/${toAdd.length} from ${category}...`; + + const resp = await fetch('/satellite/tracked', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(batch) + }); + const result = await resp.json().catch(() => ({})); + + if (!resp.ok || result.status !== 'success') { + throw new Error(result.message || result.error || `HTTP ${resp.status}`); } - }) - .catch(() => { - status.innerHTML = `Failed to save satellites`; - }); + addedTotal += Number(result.added || 0); + } + + _loadSatellitesFromAPI(); + status.innerHTML = `Added ${addedTotal} satellites (${data.satellites.length} total in category)`; } else { status.innerHTML = `Error: ${data.message || 'Failed to fetch'}`; } }) - .catch(() => { - status.innerHTML = `Network error`; + .catch((err) => { + const msg = err && err.message ? err.message : 'Network error'; + status.innerHTML = `Import failed: ${msg}`; }); } diff --git a/tests/test_bt_locate.py b/tests/test_bt_locate.py index 2f8313b..12a4eee 100644 --- a/tests/test_bt_locate.py +++ b/tests/test_bt_locate.py @@ -128,13 +128,21 @@ class TestLocateTarget: device.name = None assert target.matches(device) is True - def test_match_by_mac_case_insensitive(self): - target = LocateTarget(mac_address='aa:bb:cc:dd:ee:ff') - device = MagicMock() - device.device_id = 'other' - device.address = 'AA:BB:CC:DD:EE:FF' - device.name = None - assert target.matches(device) is True + def test_match_by_mac_case_insensitive(self): + target = LocateTarget(mac_address='aa:bb:cc:dd:ee:ff') + device = MagicMock() + device.device_id = 'other' + device.address = 'AA:BB:CC:DD:EE:FF' + device.name = None + assert target.matches(device) is True + + def test_match_by_mac_without_separators(self): + target = LocateTarget(mac_address='aabbccddeeff') + device = MagicMock() + device.device_id = 'other' + device.address = 'AA:BB:CC:DD:EE:FF' + device.name = None + assert target.matches(device) is True def test_match_by_name_pattern(self): target = LocateTarget(name_pattern='iPhone') @@ -243,7 +251,7 @@ class TestLocateSession: assert status['detection_count'] == 0 -class TestModuleLevelSessionManagement: +class TestModuleLevelSessionManagement: """Test module-level session functions.""" @patch('utils.bt_locate.get_bluetooth_scanner') @@ -261,9 +269,9 @@ class TestModuleLevelSessionManagement: assert get_locate_session() is None @patch('utils.bt_locate.get_bluetooth_scanner') - def test_start_replaces_existing_session(self, mock_get_scanner): - mock_scanner = MagicMock() - mock_get_scanner.return_value = mock_scanner + def test_start_replaces_existing_session(self, mock_get_scanner): + mock_scanner = MagicMock() + mock_get_scanner.return_value = mock_scanner target1 = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') session1 = start_locate_session(target1) @@ -273,6 +281,19 @@ class TestModuleLevelSessionManagement: assert get_locate_session() is session2 assert session1.active is False - assert session2.active is True - - stop_locate_session() + assert session2.active is True + + stop_locate_session() + + @patch('utils.bt_locate.get_bluetooth_scanner') + def test_start_raises_when_scanner_cannot_start(self, mock_get_scanner): + mock_scanner = MagicMock() + mock_scanner.is_scanning = False + mock_scanner.start_scan.return_value = False + status = MagicMock() + status.error = 'No adapter' + mock_scanner.get_status.return_value = status + mock_get_scanner.return_value = mock_scanner + + with pytest.raises(RuntimeError): + start_locate_session(LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')) diff --git a/utils/bt_locate.py b/utils/bt_locate.py index 2277e75..c47935f 100644 --- a/utils/bt_locate.py +++ b/utils/bt_locate.py @@ -18,13 +18,35 @@ from utils.bluetooth.models import BTDeviceAggregate from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner from utils.gps import get_current_position -logger = logging.getLogger('intercept.bt_locate') +logger = logging.getLogger('intercept.bt_locate') # Maximum trail points to retain MAX_TRAIL_POINTS = 500 # EMA smoothing factor for RSSI -EMA_ALPHA = 0.3 +EMA_ALPHA = 0.3 + + +def _normalize_mac(address: str | None) -> str | None: + """Normalize MAC string to colon-separated uppercase form when possible.""" + if not address: + return None + + text = str(address).strip().upper().replace('-', ':') + if not text: + return None + + # Handle raw 12-hex form: AABBCCDDEEFF + raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF') + if ':' not in text and len(raw) == 12: + text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2)) + + parts = text.split(':') + if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts): + return ':'.join(parts) + + # Return cleaned original when not a strict MAC (caller may still use exact matching) + return text class Environment(Enum): @@ -112,11 +134,11 @@ class LocateTarget: if target_addr_part and dev_addr == target_addr_part: return True - # Match by MAC/address (case-insensitive, normalize separators) + # Match by MAC/address (case-insensitive, normalize separators) if self.mac_address: - dev_addr = (device.address or '').upper().replace('-', ':') - target_addr = self.mac_address.upper().replace('-', ':') - if dev_addr == target_addr: + dev_addr = _normalize_mac(device.address) + target_addr = _normalize_mac(self.mac_address) + if dev_addr and target_addr and dev_addr == target_addr: return True # Match by payload fingerprint (guard against low-stability generic fingerprints) @@ -268,27 +290,33 @@ class LocateSession: # Track last RSSI per device to detect changes self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only - def start(self) -> bool: - """Start the locate session. - - Subscribes to scanner callbacks AND runs a polling thread that - checks the aggregator directly (handles bleak scan timeout). - """ - self._scanner = get_bluetooth_scanner() - self._scanner.add_device_callback(self._on_device) - - # Ensure BLE scanning is active - if not self._scanner.is_scanning: - logger.info("BT scanner not running, starting scan for locate session") - self._scanner_started_by_us = True - if not self._scanner.start_scan(mode='auto'): - logger.warning("Failed to start BT scanner for locate session") - else: - self._scanner_started_by_us = False - - self.active = True - self.started_at = datetime.now() - self._stop_event.clear() + def start(self) -> bool: + """Start the locate session. + + Subscribes to scanner callbacks AND runs a polling thread that + checks the aggregator directly (handles bleak scan timeout). + """ + self._scanner = get_bluetooth_scanner() + self._scanner.add_device_callback(self._on_device) + self._scanner_started_by_us = False + + # Ensure BLE scanning is active + if not self._scanner.is_scanning: + logger.info("BT scanner not running, starting scan for locate session") + self._scanner_started_by_us = True + if not self._scanner.start_scan(mode='auto'): + # Surface startup failure to caller and avoid leaving stale callbacks. + status = self._scanner.get_status() + reason = status.error or "unknown error" + logger.warning(f"Failed to start BT scanner for locate session: {reason}") + self._scanner.remove_device_callback(self._on_device) + self._scanner = None + self._scanner_started_by_us = False + return False + + self.active = True + self.started_at = datetime.now() + self._stop_event.clear() # Start polling thread as reliable fallback self._poll_thread = threading.Thread( @@ -550,25 +578,27 @@ _session: LocateSession | None = None _session_lock = threading.Lock() -def start_locate_session( - target: LocateTarget, - environment: Environment = Environment.OUTDOOR, - custom_exponent: float | None = None, - fallback_lat: float | None = None, +def start_locate_session( + target: LocateTarget, + environment: Environment = Environment.OUTDOOR, + custom_exponent: float | None = None, + fallback_lat: float | None = None, fallback_lon: float | None = None, ) -> LocateSession: """Start a new locate session, stopping any existing one.""" global _session - with _session_lock: - if _session and _session.active: - _session.stop() - - _session = LocateSession( - target, environment, custom_exponent, fallback_lat, fallback_lon - ) - _session.start() - return _session + with _session_lock: + if _session and _session.active: + _session.stop() + + _session = LocateSession( + target, environment, custom_exponent, fallback_lat, fallback_lon + ) + if not _session.start(): + _session = None + raise RuntimeError("Bluetooth scanner failed to start") + return _session def stop_locate_session() -> None: From aec925753ec0ae9a19e6757ce1418ad4ee35b7f4 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 20 Feb 2026 17:48:22 +0000 Subject: [PATCH 05/14] Pause BT Locate processing when mode is hidden --- static/js/modes/bt_locate.js | 165 +++++++++++++++++++++++------------ 1 file changed, 111 insertions(+), 54 deletions(-) diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 2df6e7e..8761e82 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -31,7 +31,7 @@ const BtLocate = (function() { let movementHeadMarker = null; let strongestMarker = null; let confidenceCircle = null; - let heatmapEnabled = true; + let heatmapEnabled = false; let movementEnabled = true; let autoFollowEnabled = true; let smoothingEnabled = true; @@ -39,6 +39,10 @@ const BtLocate = (function() { let pendingHeatSync = false; let mapStabilizeTimer = null; let modeActive = false; + let queuedDetection = null; + let queuedDetectionOptions = null; + let queuedDetectionTimer = null; + let lastDetectionRenderAt = 0; const MAX_HEAT_POINTS = 1200; const MAX_TRAIL_POINTS = 1200; @@ -46,8 +50,9 @@ const BtLocate = (function() { const OUTLIER_HARD_JUMP_METERS = 2000; const OUTLIER_SOFT_JUMP_METERS = 450; const OUTLIER_MAX_SPEED_MPS = 50; - const MAP_STABILIZE_INTERVAL_MS = 150; - const MAP_STABILIZE_ATTEMPTS = 28; + const MAP_STABILIZE_INTERVAL_MS = 220; + const MAP_STABILIZE_ATTEMPTS = 8; + const MIN_DETECTION_RENDER_MS = 220; const OVERLAY_STORAGE_KEYS = { heatmap: 'btLocateHeatmapEnabled', movement: 'btLocateMovementEnabled', @@ -94,7 +99,7 @@ const BtLocate = (function() { // Re-invalidate map on re-entry and ensure tiles are present if (map) { setTimeout(() => { - safeInvalidateMap(true); + safeInvalidateMap(); // Re-apply user's tile layer if tiles were lost let hasTiles = false; map.eachLayer(layer => { @@ -144,7 +149,7 @@ const BtLocate = (function() { flushPendingHeatSync(); }); setTimeout(() => { - safeInvalidateMap(true); + safeInvalidateMap(); flushPendingHeatSync(); }, 100); scheduleMapStabilization(); @@ -255,15 +260,21 @@ const BtLocate = (function() { }); } - function stop() { - fetch('/bt_locate/stop', { method: 'POST' }) - .then(r => r.json()) - .then(() => { - showIdleUI(); - disconnectSSE(); - stopAudio(); - }) - .catch(err => console.error('[BtLocate] Stop error:', err)); + function stop() { + fetch('/bt_locate/stop', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + queuedDetection = null; + queuedDetectionOptions = null; + showIdleUI(); + disconnectSSE(); + stopAudio(); + }) + .catch(err => console.error('[BtLocate] Stop error:', err)); } function showActiveUI() { @@ -274,12 +285,18 @@ const BtLocate = (function() { show('btLocateHud'); } - function showIdleUI() { - const startBtn = document.getElementById('btLocateStartBtn'); - const stopBtn = document.getElementById('btLocateStopBtn'); - if (startBtn) startBtn.style.display = 'inline-block'; - if (stopBtn) stopBtn.style.display = 'none'; - hide('btLocateHud'); + function showIdleUI() { + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + queuedDetection = null; + queuedDetectionOptions = null; + const startBtn = document.getElementById('btLocateStartBtn'); + const stopBtn = document.getElementById('btLocateStopBtn'); + if (startBtn) startBtn.style.display = 'inline-block'; + if (stopBtn) stopBtn.style.display = 'none'; + hide('btLocateHud'); hide('btLocateScanStatus'); } @@ -475,7 +492,42 @@ const BtLocate = (function() { } } + function flushQueuedDetection() { + if (!queuedDetection) return; + const event = queuedDetection; + const options = queuedDetectionOptions || {}; + queuedDetection = null; + queuedDetectionOptions = null; + queuedDetectionTimer = null; + renderDetection(event, options); + } + function handleDetection(event, options = {}) { + if (!modeActive) { + return; + } + const now = Date.now(); + if (options.force || (now - lastDetectionRenderAt) >= MIN_DETECTION_RENDER_MS) { + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + queuedDetection = null; + queuedDetectionOptions = null; + renderDetection(event, options); + return; + } + + // Keep only the freshest event while throttled. + queuedDetection = event; + queuedDetectionOptions = options; + if (!queuedDetectionTimer) { + queuedDetectionTimer = setTimeout(flushQueuedDetection, MIN_DETECTION_RENDER_MS); + } + } + + function renderDetection(event, options = {}) { + lastDetectionRenderAt = Date.now(); const d = event?.data || event; if (!d) return; const detectionKey = buildDetectionKey(d); @@ -906,7 +958,7 @@ const BtLocate = (function() { } function ensureHeatLayer() { - if (!map || typeof L === 'undefined' || typeof L.heatLayer !== 'function') return; + if (!map || !heatmapEnabled || typeof L === 'undefined' || typeof L.heatLayer !== 'function') return; if (!heatLayer) { heatLayer = L.heatLayer([], HEAT_LAYER_OPTIONS); } @@ -914,6 +966,13 @@ const BtLocate = (function() { function syncHeatLayer() { if (!map) return; + if (!heatmapEnabled) { + if (heatLayer && map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } + pendingHeatSync = false; + return; + } ensureHeatLayer(); if (!heatLayer) return; if (!modeActive || !isMapContainerVisible()) { @@ -930,6 +989,13 @@ const BtLocate = (function() { return; } } + if (!Array.isArray(heatPoints) || heatPoints.length === 0) { + if (map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } + pendingHeatSync = false; + return; + } try { heatLayer.setLatLngs(heatPoints); if (heatmapEnabled) { @@ -955,6 +1021,14 @@ const BtLocate = (function() { if (!modeActive) { stopMapStabilization(); + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + queuedDetection = null; + queuedDetectionOptions = null; + // Pause BT Locate frontend work when mode is hidden. + disconnectSSE(); if (heatLayer && map.hasLayer(heatLayer)) { map.removeLayer(heatLayer); } @@ -964,23 +1038,23 @@ const BtLocate = (function() { setTimeout(() => { if (!modeActive) return; - safeInvalidateMap(true); - if (typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(() => { - if (!modeActive) return; - safeInvalidateMap(true); - window.requestAnimationFrame(() => { - if (!modeActive) return; - safeInvalidateMap(true); - }); - }); - } + safeInvalidateMap(); + flushPendingHeatSync(); syncHeatLayer(); syncMovementLayer(); syncStrongestMarker(); updateConfidenceLayer(); - scheduleMapStabilization(14); + scheduleMapStabilization(8); + checkStatus(); }, 80); + + // A second pass after layout settles (sidebar/visual transitions). + setTimeout(() => { + if (!modeActive) return; + safeInvalidateMap(); + flushPendingHeatSync(); + syncHeatLayer(); + }, 260); } function isMapRenderable() { @@ -992,26 +1066,9 @@ const BtLocate = (function() { return true; } - function refreshBaseTiles() { - if (!map || typeof L === 'undefined' || typeof map.eachLayer !== 'function') return; - map.eachLayer((layer) => { - if (layer instanceof L.TileLayer && typeof layer.redraw === 'function') { - try { - layer.redraw(); - } catch (_) {} - } - }); - } - - function safeInvalidateMap(forceRecenter = false) { + function safeInvalidateMap() { if (!map || !isMapContainerVisible()) return false; - map.invalidateSize({ pan: !!forceRecenter, animate: false }); - if (forceRecenter) { - const center = map.getCenter(); - const zoom = map.getZoom(); - map.setView(center, zoom, { animate: false }); - } - refreshBaseTiles(); + map.invalidateSize({ pan: false, animate: false }); return true; } @@ -1032,7 +1089,7 @@ const BtLocate = (function() { stopMapStabilization(); return; } - if (safeInvalidateMap(true)) { + if (safeInvalidateMap()) { flushPendingHeatSync(); syncMovementLayer(); syncStrongestMarker(); @@ -1706,7 +1763,7 @@ const BtLocate = (function() { } function invalidateMap() { - if (safeInvalidateMap(true)) { + if (safeInvalidateMap()) { flushPendingHeatSync(); syncMovementLayer(); syncStrongestMarker(); From e3860163496ec4fa25c568cd517ab9d48b58f0cf Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 20 Feb 2026 18:03:06 +0000 Subject: [PATCH 06/14] Default dashboard assets/fonts to local bundles --- routes/offline.py | 15 ++++++++------- static/js/core/settings-manager.js | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/routes/offline.py b/routes/offline.py index ff25922..51326f7 100644 --- a/routes/offline.py +++ b/routes/offline.py @@ -9,13 +9,14 @@ import os offline_bp = Blueprint('offline', __name__, url_prefix='/offline') # Default offline settings -OFFLINE_DEFAULTS = { - 'offline.enabled': False, - 'offline.assets_source': 'cdn', - 'offline.fonts_source': 'cdn', - 'offline.tile_provider': 'cartodb_dark_cyan', - 'offline.tile_server_url': '' -} +OFFLINE_DEFAULTS = { + 'offline.enabled': False, + # Default to bundled assets/fonts to avoid third-party CDN privacy blocks. + 'offline.assets_source': 'local', + 'offline.fonts_source': 'local', + 'offline.tile_provider': 'cartodb_dark_cyan', + 'offline.tile_server_url': '' +} # Asset paths to check ASSET_PATHS = { diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index f6983fc..cbbabf7 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -6,8 +6,8 @@ const Settings = { // Default settings defaults: { 'offline.enabled': false, - 'offline.assets_source': 'cdn', - 'offline.fonts_source': 'cdn', + 'offline.assets_source': 'local', + 'offline.fonts_source': 'local', 'offline.tile_provider': 'cartodb_dark_cyan', 'offline.tile_server_url': '' }, From 167f10c7f78308515bee8bcfd570dead59a30152 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 20 Feb 2026 18:57:06 +0000 Subject: [PATCH 07/14] Harden BT Locate handoff matching and start flow --- routes/bt_locate.py | 21 ++-- static/js/modes/bt_locate.js | 152 +++++++++++++++++++++------ utils/bt_locate.py | 195 ++++++++++++++++++++++------------- 3 files changed, 255 insertions(+), 113 deletions(-) diff --git a/routes/bt_locate.py b/routes/bt_locate.py index 85bab64..dda456d 100644 --- a/routes/bt_locate.py +++ b/routes/bt_locate.py @@ -143,17 +143,18 @@ def stop_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: +@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, - }) - - return jsonify(session.get_status()) + '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']) diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 8761e82..64bad1a 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -43,6 +43,7 @@ const BtLocate = (function() { let queuedDetectionOptions = null; let queuedDetectionTimer = null; let lastDetectionRenderAt = 0; + let startRequestInFlight = false; const MAX_HEAT_POINTS = 1200; const MAX_TRAIL_POINTS = 1200; @@ -72,6 +73,20 @@ const BtLocate = (function() { 1.0: '#ef4444', }, }; + const BT_LOCATE_DEBUG = (() => { + try { + const params = new URLSearchParams(window.location.search || ''); + return params.get('btlocate_debug') === '1' || + localStorage.getItem('btLocateDebug') === 'true'; + } catch (_) { + return false; + } + })(); + + function debugLog() { + if (!BT_LOCATE_DEBUG) return; + console.log.apply(console, arguments); + } function getMapContainer() { if (!map || typeof map.getContainer !== 'function') return null; @@ -90,6 +105,69 @@ const BtLocate = (function() { return true; } + function statusUrl() { + try { + const params = new URLSearchParams(window.location.search || ''); + const debugFlag = params.get('btlocate_debug') === '1' || + localStorage.getItem('btLocateDebug') === 'true'; + return debugFlag ? '/bt_locate/status?debug=1' : '/bt_locate/status'; + } catch (_) { + return '/bt_locate/status'; + } + } + + function coerceLocation(lat, lon) { + const nLat = Number(lat); + const nLon = Number(lon); + if (!isFinite(nLat) || !isFinite(nLon)) return null; + if (nLat < -90 || nLat > 90 || nLon < -180 || nLon > 180) return null; + return { lat: nLat, lon: nLon }; + } + + function resolveFallbackLocation() { + try { + if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) { + const shared = ObserverLocation.getShared(); + const normalized = coerceLocation(shared?.lat, shared?.lon); + if (normalized) return normalized; + } + } catch (_) {} + + try { + const stored = localStorage.getItem('observerLocation'); + if (stored) { + const parsed = JSON.parse(stored); + const normalized = coerceLocation(parsed?.lat, parsed?.lon); + if (normalized) return normalized; + } + } catch (_) {} + + try { + const normalized = coerceLocation( + localStorage.getItem('observerLat'), + localStorage.getItem('observerLon') + ); + if (normalized) return normalized; + } catch (_) {} + + return coerceLocation(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON); + } + + function setStartButtonBusy(busy) { + const startBtn = document.getElementById('btLocateStartBtn'); + if (!startBtn) return; + if (busy) { + if (!startBtn.dataset.defaultLabel) { + startBtn.dataset.defaultLabel = startBtn.textContent || 'Start Locate'; + } + startBtn.disabled = true; + startBtn.textContent = 'Starting...'; + return; + } + startBtn.disabled = false; + startBtn.textContent = startBtn.dataset.defaultLabel || 'Start Locate'; + } + function init() { modeActive = true; loadOverlayPreferences(); @@ -166,7 +244,7 @@ const BtLocate = (function() { } function checkStatus() { - fetch('/bt_locate/status') + fetch(statusUrl()) .then(r => r.json()) .then(data => { if (data.active) { @@ -191,6 +269,9 @@ const BtLocate = (function() { } function start() { + if (startRequestInFlight) { + return; + } const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value); const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); const irk = document.getElementById('btLocateIrk')?.value.trim(); @@ -205,23 +286,25 @@ const BtLocate = (function() { if (handoffData?.known_name) body.known_name = handoffData.known_name; if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer; if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi; - - // Include user location as fallback when GPS unavailable - const userLat = localStorage.getItem('observerLat'); - const userLon = localStorage.getItem('observerLon'); - if (userLat !== null && userLon !== null) { - body.fallback_lat = parseFloat(userLat); - body.fallback_lon = parseFloat(userLon); + + // Include user location as fallback when GPS unavailable + const fallbackLocation = resolveFallbackLocation(); + if (fallbackLocation) { + body.fallback_lat = fallbackLocation.lat; + body.fallback_lon = fallbackLocation.lon; } - console.log('[BtLocate] Starting with body:', body); + debugLog('[BtLocate] Starting with body:', body); if (!body.mac_address && !body.name_pattern && !body.irk_hex && !body.device_id && !body.device_key && !body.fingerprint_id) { alert('Please provide at least one target identifier or use hand-off from Bluetooth mode.'); return; } - + + startRequestInFlight = true; + setStartButtonBusy(true); + fetch('/bt_locate/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -251,12 +334,17 @@ const BtLocate = (function() { updateScanStatus(data.session); // Restore any existing trail (e.g. from a stop/start cycle) restoreTrail(); + pollStatus(); } }) .catch(err => { console.error('[BtLocate] Start error:', err); alert('BT Locate failed to start: ' + (err?.message || 'Unknown error')); showIdleUI(); + }) + .finally(() => { + startRequestInFlight = false; + setStartButtonBusy(false); }); } @@ -277,15 +365,18 @@ const BtLocate = (function() { .catch(err => console.error('[BtLocate] Stop error:', err)); } - function showActiveUI() { - const startBtn = document.getElementById('btLocateStartBtn'); - const stopBtn = document.getElementById('btLocateStopBtn'); - if (startBtn) startBtn.style.display = 'none'; + function showActiveUI() { + setStartButtonBusy(false); + const startBtn = document.getElementById('btLocateStartBtn'); + const stopBtn = document.getElementById('btLocateStopBtn'); + if (startBtn) startBtn.style.display = 'none'; if (stopBtn) stopBtn.style.display = 'inline-block'; show('btLocateHud'); } function showIdleUI() { + startRequestInFlight = false; + setStartButtonBusy(false); if (queuedDetectionTimer) { clearTimeout(queuedDetectionTimer); queuedDetectionTimer = null; @@ -321,13 +412,13 @@ const BtLocate = (function() { function connectSSE() { if (eventSource) eventSource.close(); - console.log('[BtLocate] Connecting SSE stream'); + debugLog('[BtLocate] Connecting SSE stream'); eventSource = new EventSource('/bt_locate/stream'); eventSource.addEventListener('detection', function(e) { try { const event = JSON.parse(e.data); - console.log('[BtLocate] Detection event:', event); + debugLog('[BtLocate] Detection event:', event); handleDetection(event); } catch (err) { console.error('[BtLocate] Parse error:', err); @@ -346,9 +437,10 @@ const BtLocate = (function() { } }; - // Start polling fallback (catches data even if SSE fails) - startPolling(); - } + // Start polling fallback (catches data even if SSE fails) + startPolling(); + pollStatus(); + } function disconnectSSE() { if (eventSource) { @@ -394,10 +486,10 @@ const BtLocate = (function() { if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0'); } - function pollStatus() { - fetch('/bt_locate/status') - .then(r => r.json()) - .then(data => { + function pollStatus() { + fetch(statusUrl()) + .then(r => r.json()) + .then(data => { if (!data.active) { showIdleUI(); disconnectSSE(); @@ -1509,7 +1601,7 @@ const BtLocate = (function() { if (typeof showNotification === 'function') { showNotification(title, message); } else { - console.log('[BtLocate] ' + title + ': ' + message); + debugLog('[BtLocate] ' + title + ': ' + message); } } @@ -1600,7 +1692,7 @@ const BtLocate = (function() { // Resume must happen within a user gesture handler const ctx = audioCtx; ctx.resume().then(() => { - console.log('[BtLocate] AudioContext state:', ctx.state); + debugLog('[BtLocate] AudioContext state:', ctx.state); // Confirmation beep so user knows audio is working playTone(600, 0.08); }); @@ -1621,14 +1713,14 @@ const BtLocate = (function() { btn.classList.toggle('active', btn.dataset.env === env); }); // Push to running session if active - fetch('/bt_locate/status').then(r => r.json()).then(data => { - if (data.active) { - fetch('/bt_locate/environment', { + fetch(statusUrl()).then(r => r.json()).then(data => { + if (data.active) { + fetch('/bt_locate/environment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ environment: env }), }).then(r => r.json()).then(res => { - console.log('[BtLocate] Environment updated:', res); + debugLog('[BtLocate] Environment updated:', res); }); } }).catch(() => {}); @@ -1645,7 +1737,7 @@ const BtLocate = (function() { } function handoff(deviceInfo) { - console.log('[BtLocate] Handoff received:', deviceInfo); + debugLog('[BtLocate] Handoff received:', deviceInfo); handoffData = deviceInfo; // Populate fields diff --git a/utils/bt_locate.py b/utils/bt_locate.py index c47935f..331e79d 100644 --- a/utils/bt_locate.py +++ b/utils/bt_locate.py @@ -7,12 +7,13 @@ distance estimation, and proximity alerts for search and rescue operations. from __future__ import annotations -import logging -import queue -import threading -from dataclasses import dataclass -from datetime import datetime -from enum import Enum +import logging +import queue +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum from utils.bluetooth.models import BTDeviceAggregate from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner @@ -20,12 +21,17 @@ from utils.gps import get_current_position logger = logging.getLogger('intercept.bt_locate') -# Maximum trail points to retain -MAX_TRAIL_POINTS = 500 - -# EMA smoothing factor for RSSI +# Maximum trail points to retain +MAX_TRAIL_POINTS = 500 + +# EMA smoothing factor for RSSI EMA_ALPHA = 0.3 +# Polling/restart tuning for scanner resilience without high CPU churn. +POLL_INTERVAL_SECONDS = 1.5 +SCAN_RESTART_BACKOFF_SECONDS = 8.0 +NO_MATCH_LOG_EVERY_POLLS = 10 + def _normalize_mac(address: str | None) -> str | None: """Normalize MAC string to colon-separated uppercase form when possible.""" @@ -47,6 +53,22 @@ def _normalize_mac(address: str | None) -> str | None: # Return cleaned original when not a strict MAC (caller may still use exact matching) return text + + +def _address_looks_like_rpa(address: str | None) -> bool: + """ + Return True when an address looks like a Resolvable Private Address. + + RPA check: most-significant two bits of the first octet are `01`. + """ + normalized = _normalize_mac(address) + if not normalized: + return False + try: + first_octet = int(normalized.split(':', 1)[0], 16) + except (ValueError, TypeError): + return False + return (first_octet >> 6) == 1 class Environment(Enum): @@ -116,8 +138,27 @@ class LocateTarget: known_name: str | None = None known_manufacturer: str | None = None last_known_rssi: int | None = None + _cached_irk_hex: str | None = field(default=None, init=False, repr=False) + _cached_irk_bytes: bytes | None = field(default=None, init=False, repr=False) - def matches(self, device: BTDeviceAggregate) -> bool: + def _get_irk_bytes(self) -> bytes | None: + """Parse/cache target IRK bytes once for repeated match checks.""" + if not self.irk_hex: + return None + if self._cached_irk_hex == self.irk_hex: + return self._cached_irk_bytes + self._cached_irk_hex = self.irk_hex + self._cached_irk_bytes = None + try: + parsed = bytes.fromhex(self.irk_hex) + except (ValueError, TypeError): + return None + if len(parsed) != 16: + return None + self._cached_irk_bytes = parsed + return parsed + + def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> bool: """Check if a device matches this target.""" # Match by stable device key (survives MAC randomization for many devices) if self.device_key and getattr(device, 'device_key', None) == self.device_key: @@ -141,21 +182,23 @@ class LocateTarget: if dev_addr and target_addr and dev_addr == target_addr: return True - # Match by payload fingerprint (guard against low-stability generic fingerprints) + # Match by payload fingerprint. + # For explicit hand-off sessions, allow exact fingerprint matches even if + # stability is still warming up. if self.fingerprint_id: dev_fp = getattr(device, 'payload_fingerprint_id', None) dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0 - if dev_fp and dev_fp == self.fingerprint_id and dev_fp_stability >= 0.35: - return True + if dev_fp and dev_fp == self.fingerprint_id: + if dev_fp_stability >= 0.35: + return True + if any([self.device_id, self.device_key, self.mac_address, self.known_name]): + return True # Match by RPA resolution - if self.irk_hex: - try: - irk = bytes.fromhex(self.irk_hex) - if len(irk) == 16 and device.address and resolve_rpa(irk, device.address): - return True - except (ValueError, TypeError): - pass + if self.irk_hex and device.address and _address_looks_like_rpa(device.address): + irk = irk_bytes or self._get_irk_bytes() + if irk and resolve_rpa(irk, device.address): + return True # Match by name pattern if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower(): @@ -257,7 +300,7 @@ class LocateSession: self.environment = environment self.fallback_lat = fallback_lat self.fallback_lon = fallback_lon - self._lock = threading.Lock() + self._lock = threading.Lock() # Distance estimator n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value @@ -281,7 +324,9 @@ class LocateSession: # Debug counters self.callback_call_count = 0 self.poll_count = 0 - self._last_seen_device: str | None = None + self._last_seen_device: str | None = None + self._last_scan_restart_attempt = 0.0 + self._target_irk = target._get_irk_bytes() # Scanner reference self._scanner: BluetoothScanner | None = None @@ -304,6 +349,7 @@ class LocateSession: if not self._scanner.is_scanning: logger.info("BT scanner not running, starting scan for locate session") self._scanner_started_by_us = True + self._last_scan_restart_attempt = time.monotonic() if not self._scanner.start_scan(mode='auto'): # Surface startup failure to caller and avoid leaving stale callbacks. status = self._scanner.get_status() @@ -342,37 +388,40 @@ class LocateSession: def _poll_loop(self) -> None: """Poll scanner aggregator for target device updates.""" - while not self._stop_event.is_set(): - self._stop_event.wait(timeout=1.5) - if self._stop_event.is_set(): - break - try: - self._check_aggregator() - except Exception as e: - logger.error(f"Locate poll error: {e}") + while not self._stop_event.is_set(): + self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS) + if self._stop_event.is_set(): + break + try: + self._check_aggregator() + except Exception as e: + logger.error(f"Locate poll error: {e}") def _check_aggregator(self) -> None: """Check the scanner's aggregator for the target device.""" if not self._scanner: return - self.poll_count += 1 - - # Restart scan if it expired (bleak 10s timeout) - if not self._scanner.is_scanning: - logger.info("Scanner stopped, restarting for locate session") - self._scanner.start_scan(mode='auto') - - # Check devices seen within a recent window. Using a short window + self.poll_count += 1 + + # Restart scan if it expired (bleak 10s timeout) + if not self._scanner.is_scanning: + now = time.monotonic() + if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS: + self._last_scan_restart_attempt = now + logger.info("Scanner stopped, restarting for locate session") + self._scanner.start_scan(mode='auto') + + # Check devices seen within a recent window. Using a short window # (rather than the aggregator's full 120s) so that once a device # goes silent its stale RSSI stops producing detections. The window # must survive bleak's 10s scan cycle + restart gap (~3s). devices = self._scanner.get_devices(max_age_seconds=15) - found_target = False - for device in devices: - if not self.target.matches(device): - continue - found_target = True + found_target = False + for device in devices: + if not self.target.matches(device, irk_bytes=self._target_irk): + continue + found_target = True rssi = device.rssi_current if rssi is None: continue @@ -380,10 +429,14 @@ class LocateSession: break # One match per poll cycle is sufficient # Log periodically for debugging - if self.poll_count % 20 == 0 or (self.poll_count <= 5) or not found_target: - logger.info( - f"Poll #{self.poll_count}: {len(devices)} devices, " - f"target_found={found_target}, " + if ( + self.poll_count <= 5 + or self.poll_count % 20 == 0 + or (not found_target and self.poll_count % NO_MATCH_LOG_EVERY_POLLS == 0) + ): + logger.info( + f"Poll #{self.poll_count}: {len(devices)} devices, " + f"target_found={found_target}, " f"detections={self.detection_count}, " f"scanning={self._scanner.is_scanning}" ) @@ -396,8 +449,8 @@ class LocateSession: self.callback_call_count += 1 self._last_seen_device = f"{device.device_id}|{device.name}" - if not self.target.matches(device): - return + if not self.target.matches(device, irk_bytes=self._target_irk): + return rssi = device.rssi_current if rssi is None: @@ -425,13 +478,9 @@ class LocateSession: band = DistanceEstimator.proximity_band(distance) # Check RPA resolution - rpa_resolved = False - if self.target.irk_hex and device.address: - try: - irk = bytes.fromhex(self.target.irk_hex) - rpa_resolved = resolve_rpa(irk, device.address) - except (ValueError, TypeError): - pass + rpa_resolved = False + if self._target_irk and device.address and _address_looks_like_rpa(device.address): + rpa_resolved = resolve_rpa(self._target_irk, device.address) # GPS tag — prefer live GPS, fall back to user-set coordinates gps_pos = get_current_position() @@ -493,15 +542,15 @@ class LocateSession: with self._lock: return [p.to_dict() for p in self.trail if p.lat is not None] - def get_status(self) -> dict: - """Get session status.""" - gps_pos = get_current_position() + def get_status(self, include_debug: bool = False) -> dict: + """Get session status.""" + gps_pos = get_current_position() # Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA # deadlock: get_status would hold self._lock then wait on # aggregator._lock, while _poll_loop holds aggregator._lock then # waits on self._lock in _record_detection. - debug_devices = self._debug_device_sample() + debug_devices = self._debug_device_sample() if include_debug else [] scanner_running = self._scanner.is_scanning if self._scanner else False scanner_device_count = self._scanner.device_count if self._scanner else 0 callback_registered = ( @@ -537,8 +586,8 @@ class LocateSession: 'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) if self.trail else None, 'latest_distance': round(self.trail[-1].estimated_distance, 2) if self.trail else None, 'latest_band': self.trail[-1].proximity_band if self.trail else None, - 'debug_devices': debug_devices, - } + 'debug_devices': debug_devices, + } def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None: """Update the environment and recalculate distance estimator.""" @@ -553,16 +602,16 @@ class LocateSession: return [] try: devices = self._scanner.get_devices(max_age_seconds=30) - return [ - { - 'id': d.device_id, - 'addr': d.address, - 'name': d.name, - 'rssi': d.rssi_current, - 'match': self.target.matches(d), - } - for d in devices[:8] - ] + return [ + { + 'id': d.device_id, + 'addr': d.address, + 'name': d.name, + 'rssi': d.rssi_current, + 'match': self.target.matches(d, irk_bytes=self._target_irk), + } + for d in devices[:8] + ] except Exception: return [] From fb2a12773abe2789eade688ae5cd1e3f8efeb15c Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 20 Feb 2026 19:11:21 +0000 Subject: [PATCH 08/14] Force local dashboard assets and quiet BT locate warnings --- app.py | 39 ++++++++++++++++++++++++------------ static/js/modes/bt_locate.js | 6 +++--- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/app.py b/app.py index cfb6d95..da0dc8e 100644 --- a/app.py +++ b/app.py @@ -96,19 +96,32 @@ def add_security_headers(response): # CONTEXT PROCESSORS # ============================================ -@app.context_processor -def inject_offline_settings(): - """Inject offline settings into all templates.""" - from utils.database import get_setting - return { - 'offline_settings': { - 'enabled': get_setting('offline.enabled', False), - 'assets_source': get_setting('offline.assets_source', 'cdn'), - 'fonts_source': get_setting('offline.fonts_source', 'cdn'), - 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), - 'tile_server_url': get_setting('offline.tile_server_url', '') - } - } +@app.context_processor +def inject_offline_settings(): + """Inject offline settings into all templates.""" + from utils.database import get_setting + + # Privacy-first defaults: keep dashboard assets/fonts local to avoid + # third-party tracker/storage defenses in strict browsers. + assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower() + fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower() + if assets_source not in ('local', 'cdn'): + assets_source = 'local' + if fonts_source not in ('local', 'cdn'): + fonts_source = 'local' + # Force local delivery for core dashboard pages. + assets_source = 'local' + fonts_source = 'local' + + return { + 'offline_settings': { + 'enabled': get_setting('offline.enabled', False), + 'assets_source': assets_source, + 'fonts_source': fonts_source, + 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), + 'tile_server_url': get_setting('offline.tile_server_url', '') + } + } # ============================================ diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 64bad1a..a295730 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -431,7 +431,7 @@ const BtLocate = (function() { }); eventSource.onerror = function() { - console.warn('[BtLocate] SSE error, polling fallback active'); + debugLog('[BtLocate] SSE error, polling fallback active'); if (eventSource && eventSource.readyState === EventSource.CLOSED) { eventSource = null; } @@ -645,7 +645,7 @@ const BtLocate = (function() { try { mapPointAdded = addMapMarker(d, { suppressFollow: options.suppressFollow === true }); } catch (error) { - console.warn('[BtLocate] Map update skipped:', error); + debugLog('[BtLocate] Map update skipped:', error); mapPointAdded = false; } } @@ -1103,7 +1103,7 @@ const BtLocate = (function() { if (map.hasLayer(heatLayer)) { map.removeLayer(heatLayer); } - console.warn('[BtLocate] Heatmap redraw deferred:', error); + debugLog('[BtLocate] Heatmap redraw deferred:', error); } } From 00be3e940a6c0ed693e6746ccea4c0d6b5331d37 Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 21 Feb 2026 14:22:59 +0000 Subject: [PATCH 09/14] Fix proximity radar hover jitter without breaking device rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace capture-phase mouseenter/mouseleave with bubbling mouseover/mouseout for tracking hover state in the ProximityRadar component. The capture-phase approach caused two problems: 1. Moving between sibling child elements (hit-area → dot circle) fired mouseleave, prematurely clearing isHovered and triggering a DOM rebuild that caused visible jitter. 2. When renderDevices() rebuilt innerHTML, the browser fired mouseleave for the destroyed element with relatedTarget pointing at the newly created element at the same position, leaving isHovered permanently stuck at true and suppressing all future renders. The fix uses mouseover/mouseout (which bubble) with devicesGroup.contains() to reliably detect whether the cursor genuinely left the device group, immune to innerHTML rebuilds. Fixes both WiFi and Bluetooth proximity radars as they share this component. Closes #143. Co-Authored-By: Claude Sonnet 4.6 --- static/js/components/proximity-radar.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js index 9cd43d1..eaf7a94 100644 --- a/static/js/components/proximity-radar.js +++ b/static/js/components/proximity-radar.js @@ -134,21 +134,28 @@ const ProximityRadar = (function() { } }); - devicesGroup.addEventListener('mouseenter', (e) => { + // mouseover/mouseout bubble, so we get events from all descendants. + // Use devicesGroup.contains(relatedTarget) to detect true entry/exit + // rather than capture-phase mouseenter/mouseleave, which can leave + // isHovered stuck when innerHTML is rebuilt under the cursor. + devicesGroup.addEventListener('mouseover', (e) => { if (e.target.closest('.radar-device')) { isHovered = true; } - }, true); // capture phase so we catch enter on child elements + }); - devicesGroup.addEventListener('mouseleave', (e) => { - if (e.target.closest('.radar-device')) { + devicesGroup.addEventListener('mouseout', (e) => { + if (!e.target.closest('.radar-device')) return; + // Only clear hover when the mouse leaves the group entirely — + // moving between sibling children keeps relatedTarget inside the group. + if (!devicesGroup.contains(e.relatedTarget)) { isHovered = false; if (renderPending) { renderPending = false; renderDevices(); } } - }, true); + }); // Add sweep animation animateSweep(); From 00681840c845e14a11fad80994e95a051175e24b Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 21 Feb 2026 14:29:41 +0000 Subject: [PATCH 10/14] Rewrite proximity radar to use in-place DOM updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of rebuilding devicesGroup.innerHTML on every render, mutate existing SVG elements in-place (update transforms, attributes, class names) and only create/remove elements when devices genuinely appear or disappear from the visible set. This eliminates the root cause of both the jitter and the blank-radar regression: hover state can never be disrupted by a render because the DOM elements under the cursor are never destroyed. The isHovered / renderPending / interactionLockUntil state machine and its associated mouseover/mouseout listeners are removed entirely — they are no longer needed. A shared buildSelectRing() helper deduplicates the animated selection ring construction used by renderDevices() and applySelectionToElement(). Closes #143. Co-Authored-By: Claude Sonnet 4.6 --- static/js/components/proximity-radar.js | 224 ++++++++++++++++-------- 1 file changed, 155 insertions(+), 69 deletions(-) diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js index eaf7a94..4f7a444 100644 --- a/static/js/components/proximity-radar.js +++ b/static/js/components/proximity-radar.js @@ -33,10 +33,7 @@ const ProximityRadar = (function() { let activeFilter = null; let onDeviceClick = null; let selectedDeviceKey = null; - let isHovered = false; - let renderPending = false; let renderTimer = null; - let interactionLockUntil = 0; // timestamp: suppress renders briefly after click /** * Initialize the radar component @@ -128,35 +125,10 @@ const ProximityRadar = (function() { if (!deviceEl) return; const deviceKey = deviceEl.getAttribute('data-device-key'); if (onDeviceClick && deviceKey) { - // Lock out re-renders briefly so the DOM stays stable after click - interactionLockUntil = Date.now() + 500; onDeviceClick(deviceKey); } }); - // mouseover/mouseout bubble, so we get events from all descendants. - // Use devicesGroup.contains(relatedTarget) to detect true entry/exit - // rather than capture-phase mouseenter/mouseleave, which can leave - // isHovered stuck when innerHTML is rebuilt under the cursor. - devicesGroup.addEventListener('mouseover', (e) => { - if (e.target.closest('.radar-device')) { - isHovered = true; - } - }); - - devicesGroup.addEventListener('mouseout', (e) => { - if (!e.target.closest('.radar-device')) return; - // Only clear hover when the mouse leaves the group entirely — - // moving between sibling children keeps relatedTarget inside the group. - if (!devicesGroup.contains(e.relatedTarget)) { - isHovered = false; - if (renderPending) { - renderPending = false; - renderDevices(); - } - } - }); - // Add sweep animation animateSweep(); } @@ -198,17 +170,10 @@ const ProximityRadar = (function() { function updateDevices(deviceList) { if (isPaused) return; - // Update device map deviceList.forEach(device => { devices.set(device.device_key, device); }); - // Defer render while user is hovering or interacting to prevent DOM rebuild flicker - if (isHovered || Date.now() < interactionLockUntil) { - renderPending = true; - return; - } - // Debounce rapid updates (e.g. per-device SSE events) if (renderTimer) clearTimeout(renderTimer); renderTimer = setTimeout(() => { @@ -218,7 +183,9 @@ const ProximityRadar = (function() { } /** - * Render device dots on the radar + * Render device dots on the radar using in-place DOM updates. + * Elements are never destroyed and recreated — only their attributes and + * transforms are mutated — so hover state is never disturbed by a render. */ function renderDevices() { const devicesGroup = svg.querySelector('.radar-devices'); @@ -226,6 +193,7 @@ const ProximityRadar = (function() { const center = CONFIG.size / 2; const maxRadius = center - CONFIG.padding; + const ns = 'http://www.w3.org/2000/svg'; // Filter devices let visibleDevices = Array.from(devices.values()); @@ -241,47 +209,165 @@ const ProximityRadar = (function() { visibleDevices = visibleDevices.filter(d => !d.in_baseline); } - // Build SVG for each device - const dots = visibleDevices.map(device => { - // Calculate position - const { x, y, radius } = calculateDevicePosition(device, center, maxRadius); + const visibleKeys = new Set(visibleDevices.map(d => d.device_key)); - // Calculate dot size based on confidence + // Remove elements for devices no longer in the visible set + devicesGroup.querySelectorAll('.radar-device-wrapper').forEach(el => { + if (!visibleKeys.has(el.getAttribute('data-device-key'))) { + el.remove(); + } + }); + + visibleDevices.forEach(device => { + const { x, y } = calculateDevicePosition(device, center, maxRadius); const confidence = device.distance_confidence || 0.5; const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; - - // Get color based on proximity band const color = getBandColor(device.proximity_band); - - // Check if newly seen (pulse animation) const isNew = device.age_seconds < 5; - const pulseClass = isNew ? 'radar-dot-pulse' : ''; - const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey; - - // Hit area size (prevents hover flicker when scaling) + const isSelected = !!(selectedDeviceKey && device.device_key === selectedDeviceKey); const hitAreaSize = Math.max(dotSize * 2, 15); + const key = device.device_key; - return ` - - - - - ${isSelected ? ` - - - ` : ''} - - ${device.is_new && !isSelected ? `` : ''} - ${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm) - - - `; - }).join(''); + const existing = devicesGroup.querySelector( + `.radar-device-wrapper[data-device-key="${CSS.escape(key)}"]` + ); - devicesGroup.innerHTML = dots; + if (existing) { + // ── In-place update: mutate attributes, never recreate ── + existing.setAttribute('transform', `translate(${x}, ${y})`); + + const innerG = existing.querySelector('.radar-device'); + if (innerG) { + innerG.className.baseVal = + `radar-device${isNew ? ' radar-dot-pulse' : ''}${isSelected ? ' selected' : ''}`; + + const hitArea = innerG.querySelector('.radar-device-hitarea'); + if (hitArea) hitArea.setAttribute('r', hitAreaSize); + + const dot = innerG.querySelector('.radar-dot'); + if (dot) { + dot.setAttribute('r', dotSize); + dot.setAttribute('fill', color); + dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5); + dot.setAttribute('stroke', isSelected ? '#00d4ff' : color); + dot.setAttribute('stroke-width', isSelected ? 2 : 1); + } + + const title = innerG.querySelector('title'); + if (title) { + title.textContent = + `${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)`; + } + + // Selection ring: add if newly selected, remove if deselected + let ring = innerG.querySelector('.radar-select-ring'); + if (isSelected && !ring) { + ring = buildSelectRing(ns, dotSize); + const hitAreaEl = innerG.querySelector('.radar-device-hitarea'); + innerG.insertBefore(ring, hitAreaEl ? hitAreaEl.nextSibling : innerG.firstChild); + } else if (!isSelected && ring) { + ring.remove(); + } + + // New-device indicator ring + let newRing = innerG.querySelector('.radar-new-ring'); + if (device.is_new && !isSelected) { + if (!newRing) { + newRing = document.createElementNS(ns, 'circle'); + newRing.classList.add('radar-new-ring'); + newRing.setAttribute('fill', 'none'); + newRing.setAttribute('stroke', '#3b82f6'); + newRing.setAttribute('stroke-width', '1'); + newRing.setAttribute('stroke-dasharray', '2,2'); + innerG.appendChild(newRing); + } + newRing.setAttribute('r', dotSize + 3); + } else if (newRing) { + newRing.remove(); + } + } + } else { + // ── Create new element ── + const wrapperG = document.createElementNS(ns, 'g'); + wrapperG.classList.add('radar-device-wrapper'); + wrapperG.setAttribute('data-device-key', key); + wrapperG.setAttribute('transform', `translate(${x}, ${y})`); + + const innerG = document.createElementNS(ns, 'g'); + innerG.classList.add('radar-device'); + if (isNew) innerG.classList.add('radar-dot-pulse'); + if (isSelected) innerG.classList.add('selected'); + innerG.setAttribute('data-device-key', escapeAttr(key)); + innerG.style.cursor = 'pointer'; + + const hitArea = document.createElementNS(ns, 'circle'); + hitArea.classList.add('radar-device-hitarea'); + hitArea.setAttribute('r', hitAreaSize); + hitArea.setAttribute('fill', 'transparent'); + innerG.appendChild(hitArea); + + if (isSelected) { + innerG.appendChild(buildSelectRing(ns, dotSize)); + } + + const dot = document.createElementNS(ns, 'circle'); + dot.classList.add('radar-dot'); + dot.setAttribute('r', dotSize); + dot.setAttribute('fill', color); + dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5); + dot.setAttribute('stroke', isSelected ? '#00d4ff' : color); + dot.setAttribute('stroke-width', isSelected ? 2 : 1); + innerG.appendChild(dot); + + if (device.is_new && !isSelected) { + const newRing = document.createElementNS(ns, 'circle'); + newRing.classList.add('radar-new-ring'); + newRing.setAttribute('r', dotSize + 3); + newRing.setAttribute('fill', 'none'); + newRing.setAttribute('stroke', '#3b82f6'); + newRing.setAttribute('stroke-width', '1'); + newRing.setAttribute('stroke-dasharray', '2,2'); + innerG.appendChild(newRing); + } + + const title = document.createElementNS(ns, 'title'); + title.textContent = + `${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)`; + innerG.appendChild(title); + + wrapperG.appendChild(innerG); + devicesGroup.appendChild(wrapperG); + } + }); + } + + /** + * Build an animated SVG selection ring element + */ + function buildSelectRing(ns, dotSize) { + const ring = document.createElementNS(ns, 'circle'); + ring.classList.add('radar-select-ring'); + ring.setAttribute('r', dotSize + 8); + ring.setAttribute('fill', 'none'); + ring.setAttribute('stroke', '#00d4ff'); + ring.setAttribute('stroke-width', '2'); + ring.setAttribute('stroke-opacity', '0.8'); + + const animR = document.createElementNS(ns, 'animate'); + animR.setAttribute('attributeName', 'r'); + animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`); + animR.setAttribute('dur', '1.5s'); + animR.setAttribute('repeatCount', 'indefinite'); + ring.appendChild(animR); + + const animO = document.createElementNS(ns, 'animate'); + animO.setAttribute('attributeName', 'stroke-opacity'); + animO.setAttribute('values', '0.8;0.4;0.8'); + animO.setAttribute('dur', '1.5s'); + animO.setAttribute('repeatCount', 'indefinite'); + ring.appendChild(animO); + + return ring; } /** From f8a6d0ae709fb2543d0767c650afadcd81ea8e7c Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 21 Feb 2026 14:35:42 +0000 Subject: [PATCH 11/14] Smooth proximity radar positions with EMA and CSS transitions The remaining jitter after the in-place DOM rewrite was caused by RSSI fluctuations propagating directly into dot positions on every 200ms update cycle. Two fixes: 1. Client-side EMA (alpha=0.25) on x/y coordinates per device. Each render blends 25% toward the new raw position and retains 75% of the smoothed position, filtering high-frequency RSSI noise without hiding genuine distance changes. positionCache is keyed by device_key and cleared on device removal or radar reset. 2. CSS transition (transform 0.6s ease-out) on each wrapper element. Switching from SVG transform attribute to style.transform enables native CSS transitions, so any remaining position change (e.g. a band crossing) animates smoothly rather than snapping. Co-Authored-By: Claude Sonnet 4.6 --- static/js/components/proximity-radar.js | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js index 4f7a444..c90cc1b 100644 --- a/static/js/components/proximity-radar.js +++ b/static/js/components/proximity-radar.js @@ -25,10 +25,14 @@ const ProximityRadar = (function() { newDeviceThreshold: 30, // seconds }; + // Configuration + const POSITION_EMA_ALPHA = 0.25; // lower = more smoothing (0.25 → ~4 updates to reach 68% of a step) + // State let container = null; let svg = null; let devices = new Map(); + let positionCache = new Map(); // device_key → { x, y } smoothed position let isPaused = false; let activeFilter = null; let onDeviceClick = null; @@ -213,13 +217,24 @@ const ProximityRadar = (function() { // Remove elements for devices no longer in the visible set devicesGroup.querySelectorAll('.radar-device-wrapper').forEach(el => { - if (!visibleKeys.has(el.getAttribute('data-device-key'))) { + const k = el.getAttribute('data-device-key'); + if (!visibleKeys.has(k)) { + positionCache.delete(k); el.remove(); } }); visibleDevices.forEach(device => { - const { x, y } = calculateDevicePosition(device, center, maxRadius); + // Raw target position from distance/band + const { x: rawX, y: rawY } = calculateDevicePosition(device, center, maxRadius); + + // EMA smoothing: blend towards the new position rather than snapping, + // so RSSI noise doesn't translate 1:1 into visible movement. + const cached = positionCache.get(device.device_key); + const x = cached ? cached.x * (1 - POSITION_EMA_ALPHA) + rawX * POSITION_EMA_ALPHA : rawX; + const y = cached ? cached.y * (1 - POSITION_EMA_ALPHA) + rawY * POSITION_EMA_ALPHA : rawY; + positionCache.set(device.device_key, { x, y }); + const confidence = device.distance_confidence || 0.5; const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; const color = getBandColor(device.proximity_band); @@ -234,7 +249,7 @@ const ProximityRadar = (function() { if (existing) { // ── In-place update: mutate attributes, never recreate ── - existing.setAttribute('transform', `translate(${x}, ${y})`); + existing.style.transform = `translate(${x}px, ${y}px)`; const innerG = existing.querySelector('.radar-device'); if (innerG) { @@ -291,7 +306,8 @@ const ProximityRadar = (function() { const wrapperG = document.createElementNS(ns, 'g'); wrapperG.classList.add('radar-device-wrapper'); wrapperG.setAttribute('data-device-key', key); - wrapperG.setAttribute('transform', `translate(${x}, ${y})`); + wrapperG.style.transform = `translate(${x}px, ${y}px)`; + wrapperG.style.transition = 'transform 0.6s ease-out'; const innerG = document.createElementNS(ns, 'g'); innerG.classList.add('radar-device'); @@ -446,6 +462,7 @@ const ProximityRadar = (function() { */ function clear() { devices.clear(); + positionCache.clear(); selectedDeviceKey = null; renderDevices(); } From aba4ccd0402733a9c61e98e2b6f5ebb93988facb Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 21 Feb 2026 14:38:50 +0000 Subject: [PATCH 12/14] Fix radar jitter by using band-only positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace continuous estimated_distance_m-based radius with proximity band snapping (immediate/near/far/unknown → fixed radius ratios of 0.15/0.40/ 0.70/0.90). The proximity_band is computed server-side from rssi_ema which is already smoothed, so it changes infrequently — dots now only move when a device genuinely crosses a band boundary rather than on every RSSI fluctuation. Also removes the client-side EMA and positionCache added in the previous commit, and reverts CSS style.transform back to SVG transform attribute to avoid coordinate-system mismatch when the SVG is displayed at a scaled size. Co-Authored-By: Claude Sonnet 4.6 --- static/js/components/proximity-radar.js | 49 +++++++------------------ 1 file changed, 13 insertions(+), 36 deletions(-) diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js index c90cc1b..1b7d733 100644 --- a/static/js/components/proximity-radar.js +++ b/static/js/components/proximity-radar.js @@ -25,14 +25,10 @@ const ProximityRadar = (function() { newDeviceThreshold: 30, // seconds }; - // Configuration - const POSITION_EMA_ALPHA = 0.25; // lower = more smoothing (0.25 → ~4 updates to reach 68% of a step) - // State let container = null; let svg = null; let devices = new Map(); - let positionCache = new Map(); // device_key → { x, y } smoothed position let isPaused = false; let activeFilter = null; let onDeviceClick = null; @@ -217,24 +213,13 @@ const ProximityRadar = (function() { // Remove elements for devices no longer in the visible set devicesGroup.querySelectorAll('.radar-device-wrapper').forEach(el => { - const k = el.getAttribute('data-device-key'); - if (!visibleKeys.has(k)) { - positionCache.delete(k); + if (!visibleKeys.has(el.getAttribute('data-device-key'))) { el.remove(); } }); visibleDevices.forEach(device => { - // Raw target position from distance/band - const { x: rawX, y: rawY } = calculateDevicePosition(device, center, maxRadius); - - // EMA smoothing: blend towards the new position rather than snapping, - // so RSSI noise doesn't translate 1:1 into visible movement. - const cached = positionCache.get(device.device_key); - const x = cached ? cached.x * (1 - POSITION_EMA_ALPHA) + rawX * POSITION_EMA_ALPHA : rawX; - const y = cached ? cached.y * (1 - POSITION_EMA_ALPHA) + rawY * POSITION_EMA_ALPHA : rawY; - positionCache.set(device.device_key, { x, y }); - + const { x, y } = calculateDevicePosition(device, center, maxRadius); const confidence = device.distance_confidence || 0.5; const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; const color = getBandColor(device.proximity_band); @@ -249,7 +234,7 @@ const ProximityRadar = (function() { if (existing) { // ── In-place update: mutate attributes, never recreate ── - existing.style.transform = `translate(${x}px, ${y}px)`; + existing.setAttribute('transform', `translate(${x}, ${y})`); const innerG = existing.querySelector('.radar-device'); if (innerG) { @@ -306,8 +291,7 @@ const ProximityRadar = (function() { const wrapperG = document.createElementNS(ns, 'g'); wrapperG.classList.add('radar-device-wrapper'); wrapperG.setAttribute('data-device-key', key); - wrapperG.style.transform = `translate(${x}px, ${y}px)`; - wrapperG.style.transition = 'transform 0.6s ease-out'; + wrapperG.setAttribute('transform', `translate(${x}, ${y})`); const innerG = document.createElementNS(ns, 'g'); innerG.classList.add('radar-device'); @@ -390,22 +374,16 @@ const ProximityRadar = (function() { * Calculate device position on radar */ function calculateDevicePosition(device, center, maxRadius) { - // Calculate radius based on proximity band/distance + // Position is band-only — the band is computed server-side from rssi_ema + // (already smoothed), so it changes infrequently and never jitters. + // Using raw estimated_distance_m caused constant micro-movement as RSSI + // fluctuated on every update cycle. let radiusRatio; - const band = device.proximity_band || 'unknown'; - - if (device.estimated_distance_m != null) { - // Use actual distance (log scale) - const maxDistance = 15; - radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1)); - } else { - // Use band-based positioning - switch (band) { - case 'immediate': radiusRatio = 0.15; break; - case 'near': radiusRatio = 0.4; break; - case 'far': radiusRatio = 0.7; break; - default: radiusRatio = 0.9; break; - } + switch (device.proximity_band || 'unknown') { + case 'immediate': radiusRatio = 0.15; break; + case 'near': radiusRatio = 0.40; break; + case 'far': radiusRatio = 0.70; break; + default: radiusRatio = 0.90; break; } // Calculate angle based on device key hash (stable positioning) @@ -462,7 +440,6 @@ const ProximityRadar = (function() { */ function clear() { devices.clear(); - positionCache.clear(); selectedDeviceKey = null; renderDevices(); } From 4b225db9da8fcfa9402fb7c76cfd6bd2196ae555 Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 21 Feb 2026 14:44:46 +0000 Subject: [PATCH 13/14] Fix proximity radar jitter caused by CSS scale-on-hover feedback loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root cause was in proximity-viz.css, not the JS: .radar-device:hover { transform: scale(1.2); } When the cursor entered a .radar-device, the 1.2x scale physically moved the hit-area boundary, pushing the cursor outside it. The browser then fired mouseout, the scale reverted, the cursor was back inside, mouseover fired again, and the scale reapplied — a rapid enter/exit loop that looked like the dot jumping and dancing. Replace the geometry-changing scale with a brightness filter on the dot circle only. filter: brightness() does not affect pointer-event hit testing so there is no feedback loop, and the hover still gives clear visual feedback. Also removes the transition: transform rule that was animating the scale and contributing to the flicker. Co-Authored-By: Claude Sonnet 4.6 --- static/css/components/proximity-viz.css | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/static/css/components/proximity-viz.css b/static/css/components/proximity-viz.css index 133c09a..2a07dab 100644 --- a/static/css/components/proximity-viz.css +++ b/static/css/components/proximity-viz.css @@ -13,13 +13,11 @@ } .radar-device { - transition: transform 0.2s ease; - transform-origin: center center; cursor: pointer; } -.radar-device:hover { - transform: scale(1.2); +.radar-device:hover .radar-dot { + filter: brightness(1.5); } /* Invisible larger hit area to prevent hover flicker */ From a8e2b9d98de6982fd873b64a0b663c666e5805b3 Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 21 Feb 2026 14:51:45 +0000 Subject: [PATCH 14/14] Shrink hit areas and spread overlapping radar dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hit area: was Math.max(dotSize * 2, 15) — up to 24px radius around a 4px dot. Now the CSS hover-flicker is fixed the large hit area is unnecessary and was the reason dots activated when merely nearby. Changed to dotSize + 4 (proportional, 4px padding around the visual circle). Overlap spread: compute all band positions first, then run an iterative push-apart pass (spreadOverlappingDots) that nudges any two dots whose arc gap is smaller than 2 * maxHitArea + 2px apart. Positions within a band are stable across renders (same hash angle, same band = same output before spreading) so dots don't shuffle on every update. Z-order: sort visible devices by rssi_current ascending before rendering so the strongest signal lands last in SVG order and receives clicks when dots stack. Co-Authored-By: Claude Sonnet 4.6 --- static/js/components/proximity-radar.js | 66 +++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js index 1b7d733..c08f338 100644 --- a/static/js/components/proximity-radar.js +++ b/static/js/components/proximity-radar.js @@ -218,14 +218,28 @@ const ProximityRadar = (function() { } }); + // Sort weakest signal first so strongest renders on top (SVG z-order) + visibleDevices.sort((a, b) => (a.rssi_current || -100) - (b.rssi_current || -100)); + + // Compute all positions upfront so we can spread overlapping dots + const posMap = new Map(); visibleDevices.forEach(device => { - const { x, y } = calculateDevicePosition(device, center, maxRadius); + posMap.set(device.device_key, calculateDevicePosition(device, center, maxRadius)); + }); + + // Spread dots that land too close together within the same band. + // minGapPx = diameter of largest possible hit area + 2px breathing room. + const maxHitArea = CONFIG.dotMaxSize + 4; + spreadOverlappingDots(Array.from(posMap.values()), center, maxHitArea * 2 + 2); + + visibleDevices.forEach(device => { + const { x, y } = posMap.get(device.device_key); const confidence = device.distance_confidence || 0.5; const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; const color = getBandColor(device.proximity_band); const isNew = device.age_seconds < 5; const isSelected = !!(selectedDeviceKey && device.device_key === selectedDeviceKey); - const hitAreaSize = Math.max(dotSize * 2, 15); + const hitAreaSize = dotSize + 4; const key = device.device_key; const existing = devicesGroup.querySelector( @@ -393,7 +407,53 @@ const ProximityRadar = (function() { const x = center + Math.sin(angle) * radius; const y = center - Math.cos(angle) * radius; - return { x, y, radius }; + return { x, y, angle, radius }; + } + + /** + * Spread dots within the same band that land too close together. + * Groups entries by radius, sorts by angle, then nudges neighbours + * apart until the arc gap between any two dots is at least minGapPx. + * Positions are updated in-place on the entry objects. + */ + function spreadOverlappingDots(entries, center, minGapPx) { + const groups = new Map(); + entries.forEach(e => { + const key = Math.round(e.radius); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(e); + }); + + groups.forEach((group, r) => { + if (group.length < 2 || r < 1) return; + const minSep = minGapPx / r; // radians + + group.sort((a, b) => a.angle - b.angle); + + // Iterative push-apart (up to 8 passes) + for (let iter = 0; iter < 8; iter++) { + let moved = false; + for (let i = 0; i < group.length; i++) { + const j = (i + 1) % group.length; + let gap = group[j].angle - group[i].angle; + if (gap < 0) gap += 2 * Math.PI; + if (gap < minSep) { + const push = (minSep - gap) / 2; + group[i].angle -= push; + group[j].angle += push; + moved = true; + } + } + if (!moved) break; + } + + // Normalise angles back to [0, 2π) and recompute x/y + group.forEach(e => { + e.angle = ((e.angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + e.x = center + Math.sin(e.angle) * r; + e.y = center - Math.cos(e.angle) * r; + }); + }); } /**