From b628a5f751c626f336028df8f84adcb5a6338797 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 20 Feb 2026 17:02:16 +0000 Subject: [PATCH] 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)