mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
Add drone ops mode and retire DMR support
This commit is contained in:
-23
@@ -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/*
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<id>` | `GET` / `PUT` | Incident detail and status updates |
|
||||
| `/drone-ops/incidents/<id>/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/<id>` | `POST` | Secondary approval (if required) |
|
||||
| `/drone-ops/actions/execute/<id>` | `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.
|
||||
+1
-3
@@ -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
|
||||
|
||||
+11
-11
@@ -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)
|
||||
|
||||
-753
@@ -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
|
||||
@@ -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/<int:incident_id>', 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/<int:incident_id>', 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/<int:incident_id>/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/<int:request_id>', 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/<int:request_id>', 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/<int:request_id>', 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/<int:incident_id>/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/<int:manifest_id>', 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/<int:incident_id>/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})
|
||||
+12
-7
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Slot</span>
|
||||
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
|
||||
</div>` : '';
|
||||
const callEl = document.getElementById('dmrCurrentCall');
|
||||
if (callEl) {
|
||||
callEl.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Talkgroup</span>
|
||||
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Source ID</span>
|
||||
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
|
||||
</div>${slotInfo}
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-muted);">Time</span>
|
||||
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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 = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
|
||||
<tr>
|
||||
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
|
||||
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
|
||||
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
|
||||
<td style="padding: 3px 6px;">${call.protocol}</td>
|
||||
</tr>
|
||||
`).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;
|
||||
File diff suppressed because it is too large
Load Diff
+95
-84
@@ -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 @@
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||
<span class="mode-name">TSCM</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('droneops')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="8" r="2"/><circle cx="5" cy="16" r="2"/><circle cx="19" cy="16" r="2"/></svg></span>
|
||||
<span class="mode-name">Drone Ops</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('analytics')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg></span>
|
||||
<span class="mode-name">Analytics</span>
|
||||
@@ -606,14 +611,14 @@
|
||||
|
||||
{% include 'partials/modes/analytics.html' %}
|
||||
|
||||
{% include 'partials/modes/droneops.html' %}
|
||||
|
||||
{% include 'partials/modes/ais.html' %}
|
||||
|
||||
{% include 'partials/modes/spy-stations.html' %}
|
||||
|
||||
{% include 'partials/modes/meshtastic.html' %}
|
||||
|
||||
{% include 'partials/modes/dmr.html' %}
|
||||
|
||||
{% include 'partials/modes/websdr.html' %}
|
||||
|
||||
{% include 'partials/modes/subghz.html' %}
|
||||
@@ -1883,76 +1888,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DMR / Digital Voice Dashboard -->
|
||||
<div id="dmrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0;">
|
||||
<!-- DMR Synthesizer -->
|
||||
<div class="radio-module-box" style="padding: 10px;">
|
||||
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
|
||||
<span>SIGNAL ACTIVITY</span>
|
||||
<span id="dmrSynthStatus" style="color: var(--text-muted); font-size: 9px; font-family: var(--font-mono);">IDLE</span>
|
||||
</div>
|
||||
<canvas id="dmrSynthCanvas" style="width: 100%; height: 70px; background: rgba(0,0,0,0.4); border-radius: 4px; display: block;"></canvas>
|
||||
</div>
|
||||
<!-- Audio Output -->
|
||||
<div class="radio-module-box" style="padding: 8px 12px;">
|
||||
<audio id="dmrAudioPlayer" style="display: none;"></audio>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">AUDIO</span>
|
||||
<span id="dmrAudioStatus" style="font-size: 9px; font-family: var(--font-mono); color: var(--text-muted);">OFF</span>
|
||||
<div style="display: flex; align-items: center; gap: 4px; margin-left: auto;">
|
||||
<span style="font-size: 10px; color: var(--text-muted);">VOL</span>
|
||||
<input type="range" id="dmrAudioVolume" min="0" max="100" value="80" style="width: 80px;" oninput="setDmrAudioVolume(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||
<!-- Call History Panel -->
|
||||
<div class="radio-module-box" style="padding: 10px;">
|
||||
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
|
||||
<span>CALL HISTORY</span>
|
||||
<span id="dmrHistoryCount" style="color: var(--accent-cyan);">0 calls</span>
|
||||
</div>
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="color: var(--text-muted); border-bottom: 1px solid var(--border-color);">
|
||||
<th style="text-align: left; padding: 4px;">Time</th>
|
||||
<th style="text-align: left; padding: 4px;">Talkgroup</th>
|
||||
<th style="text-align: left; padding: 4px;">Source</th>
|
||||
<th style="text-align: left; padding: 4px;">Protocol</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dmrHistoryBody">
|
||||
<tr><td colspan="4" style="padding: 15px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Protocol Info Panel -->
|
||||
<div class="radio-module-box" style="padding: 10px;">
|
||||
<div class="module-header" style="margin-bottom: 8px; font-size: 10px;">
|
||||
<span>DECODER STATUS</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; padding: 10px;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 2px; margin-bottom: 4px;">PROTOCOL</div>
|
||||
<div id="dmrMainProtocol" style="font-size: 28px; font-weight: bold; color: var(--accent-cyan); font-family: var(--font-mono);">--</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<div style="text-align: center; padding: 8px; background: rgba(0,0,0,0.3); border-radius: 4px;">
|
||||
<div style="font-size: 9px; color: var(--text-muted);">CALLS</div>
|
||||
<div id="dmrMainCallCount" style="font-size: 22px; font-weight: bold; color: var(--accent-green); font-family: var(--font-mono);">0</div>
|
||||
</div>
|
||||
<div style="text-align: center; padding: 8px; background: rgba(0,0,0,0.3); border-radius: 4px;">
|
||||
<div style="font-size: 9px; color: var(--text-muted);">SYNCS</div>
|
||||
<div id="dmrSyncCount" style="font-size: 22px; font-weight: bold; color: var(--accent-orange); font-family: var(--font-mono);">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SubGHz Transceiver Dashboard -->
|
||||
<div id="subghzVisuals" class="subghz-visuals-container" style="display: none;">
|
||||
|
||||
@@ -3204,6 +3139,74 @@
|
||||
|
||||
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||||
|
||||
<div id="droneOpsMainPane" class="droneops-main-pane" style="display: none;">
|
||||
<div class="droneops-main-top">
|
||||
<div class="droneops-main-map-card">
|
||||
<div class="droneops-main-card-header">
|
||||
<h4>Operational Map</h4>
|
||||
<span id="droneOpsMapMeta" class="droneops-main-card-meta">Waiting for telemetry</span>
|
||||
</div>
|
||||
<div id="droneOpsMap" class="droneops-main-map"></div>
|
||||
</div>
|
||||
<div class="droneops-main-side">
|
||||
<div class="droneops-main-card">
|
||||
<div class="droneops-main-card-header">
|
||||
<h4>Live Summary</h4>
|
||||
<span id="droneOpsMainLastUpdate" class="droneops-main-card-meta">--</span>
|
||||
</div>
|
||||
<div class="droneops-main-kpis">
|
||||
<div class="droneops-main-kpi">
|
||||
<span>Detections</span>
|
||||
<strong id="droneOpsMainSummaryDetections">0</strong>
|
||||
</div>
|
||||
<div class="droneops-main-kpi">
|
||||
<span>Sources</span>
|
||||
<strong id="droneOpsMainSummarySources">0</strong>
|
||||
</div>
|
||||
<div class="droneops-main-kpi">
|
||||
<span>Track Points</span>
|
||||
<strong id="droneOpsMainSummaryTracks">0</strong>
|
||||
</div>
|
||||
<div class="droneops-main-kpi">
|
||||
<span>Telemetry IDs</span>
|
||||
<strong id="droneOpsMainSummaryTelemetry">0</strong>
|
||||
</div>
|
||||
<div class="droneops-main-kpi">
|
||||
<span>Correlations</span>
|
||||
<strong id="droneOpsMainSummaryCorrelations">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="droneops-main-card">
|
||||
<div class="droneops-main-card-header">
|
||||
<h4>Operator Correlations</h4>
|
||||
</div>
|
||||
<div id="droneOpsMainCorrelations" class="droneops-main-list compact">
|
||||
<div class="droneops-empty">No correlations yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="droneops-main-bottom">
|
||||
<div class="droneops-main-card">
|
||||
<div class="droneops-main-card-header">
|
||||
<h4>Detections</h4>
|
||||
</div>
|
||||
<div id="droneOpsMainDetections" class="droneops-main-list">
|
||||
<div class="droneops-empty">No detections yet</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="droneops-main-card">
|
||||
<div class="droneops-main-card-header">
|
||||
<h4>Telemetry Stream</h4>
|
||||
</div>
|
||||
<div id="droneOpsMainTelemetry" class="droneops-main-list">
|
||||
<div class="droneops-empty">No telemetry yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-content signal-feed" id="output">
|
||||
<div class="placeholder signal-empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -3270,11 +3273,11 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/gps.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/droneops.js') }}?v={{ version }}&r=droneops6"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||||
|
||||
<script>
|
||||
@@ -3426,6 +3429,7 @@
|
||||
bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' },
|
||||
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
|
||||
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
|
||||
droneops: { label: 'Drone Ops', indicator: 'DRONE OPS', outputTitle: 'Drone Ops', group: 'intel' },
|
||||
analytics: { label: 'Analytics', indicator: 'ANALYTICS', outputTitle: 'Cross-Mode Analytics', group: 'intel' },
|
||||
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
|
||||
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
|
||||
@@ -4016,10 +4020,10 @@
|
||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
|
||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||||
document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics');
|
||||
document.getElementById('droneOpsMode')?.classList.toggle('active', mode === 'droneops');
|
||||
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
||||
|
||||
|
||||
@@ -4057,11 +4061,11 @@
|
||||
const weatherSatVisuals = document.getElementById('weatherSatVisuals');
|
||||
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
|
||||
const gpsVisuals = document.getElementById('gpsVisuals');
|
||||
const dmrVisuals = document.getElementById('dmrVisuals');
|
||||
const websdrVisuals = document.getElementById('websdrVisuals');
|
||||
const subghzVisuals = document.getElementById('subghzVisuals');
|
||||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||
const droneOpsMainPane = document.getElementById('droneOpsMainPane');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
@@ -4074,11 +4078,11 @@
|
||||
if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none';
|
||||
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
|
||||
if (gpsVisuals) gpsVisuals.style.display = mode === 'gps' ? 'flex' : 'none';
|
||||
if (dmrVisuals) dmrVisuals.style.display = mode === 'dmr' ? 'flex' : 'none';
|
||||
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
||||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||
if (droneOpsMainPane) droneOpsMainPane.style.display = mode === 'droneops' ? 'flex' : 'none';
|
||||
|
||||
// Hide sidebar by default for Meshtastic mode, show for others
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
@@ -4120,6 +4124,17 @@
|
||||
if (typeof Analytics !== 'undefined' && Analytics.destroy) Analytics.destroy();
|
||||
}
|
||||
|
||||
// Initialize/destroy Drone Ops mode
|
||||
if (mode === 'droneops') {
|
||||
document.querySelectorAll('#droneOpsMode .section.collapsed').forEach(s => s.classList.remove('collapsed'));
|
||||
if (typeof DroneOps !== 'undefined' && DroneOps.init) {
|
||||
DroneOps.init();
|
||||
if (DroneOps.invalidateMap) setTimeout(() => DroneOps.invalidateMap(), 120);
|
||||
}
|
||||
} else {
|
||||
if (typeof DroneOps !== 'undefined' && DroneOps.destroy) DroneOps.destroy();
|
||||
}
|
||||
|
||||
// Initialize/destroy Space Weather mode
|
||||
if (mode !== 'spaceweather') {
|
||||
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
|
||||
@@ -4134,7 +4149,7 @@
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') {
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'droneops' || mode === 'spaceweather') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -4154,7 +4169,7 @@
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
|
||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general') ? 'block' : 'none';
|
||||
|
||||
// Show waterfall panel if running in listening mode
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
@@ -4172,8 +4187,8 @@
|
||||
// Hide output console for modes with their own visualizations
|
||||
const outputEl = document.getElementById('output');
|
||||
const statusBar = document.querySelector('.status-bar');
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex';
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'droneops' || mode === 'spaceweather') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex';
|
||||
|
||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||
if (mode !== 'meshtastic') {
|
||||
@@ -4232,10 +4247,6 @@
|
||||
SSTVGeneral.init();
|
||||
} else if (mode === 'gps') {
|
||||
GPS.init();
|
||||
} else if (mode === 'dmr') {
|
||||
if (typeof checkDmrTools === 'function') checkDmrTools();
|
||||
if (typeof checkDmrStatus === 'function') checkDmrStatus();
|
||||
if (typeof initDmrSynthesizer === 'function') setTimeout(initDmrSynthesizer, 100);
|
||||
} else if (mode === 'websdr') {
|
||||
if (typeof initWebSDR === 'function') initWebSDR();
|
||||
} else if (mode === 'subghz') {
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
<!-- DMR / DIGITAL VOICE MODE -->
|
||||
<div id="dmrMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Digital Voice</h3>
|
||||
<div class="alpha-mode-notice">
|
||||
ALPHA: Digital Voice decoding is still in active development. Expect occasional decode instability and false protocol locks.
|
||||
</div>
|
||||
|
||||
<!-- Dependency Warning -->
|
||||
<div id="dmrToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
|
||||
<strong>Missing:</strong><br>
|
||||
<span id="dmrToolsWarningText"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Frequency (MHz)</label>
|
||||
<input type="number" id="dmrFrequency" value="462.5625" step="0.0001" style="width: 100%;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Protocol</label>
|
||||
<select id="dmrProtocol">
|
||||
<option value="auto" selected>Auto Detect (DMR/P25/D-STAR)</option>
|
||||
<option value="dmr">DMR</option>
|
||||
<option value="p25">P25</option>
|
||||
<option value="nxdn">NXDN</option>
|
||||
<option value="dstar">D-STAR</option>
|
||||
<option value="provoice">ProVoice</option>
|
||||
</select>
|
||||
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
|
||||
For NXDN and ProVoice, use manual protocol selection for best lock reliability
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Gain</label>
|
||||
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>PPM Correction</label>
|
||||
<input type="number" id="dmrPPM" value="0" min="-200" max="200" step="1" style="width: 100%;"
|
||||
title="Frequency error correction for your RTL-SDR dongle. Digital voice is very sensitive to frequency offset.">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 4px;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="dmrRelaxCrc" style="width: auto; accent-color: var(--accent-cyan);">
|
||||
<span>Relax CRC (weak signals)</span>
|
||||
</label>
|
||||
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
|
||||
Allows more frames through on marginal signals at the cost of occasional errors
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bookmarks -->
|
||||
<div class="section" style="margin-top: 8px;">
|
||||
<h3>Bookmarks</h3>
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
|
||||
<input type="number" id="dmrBookmarkFreq" placeholder="Freq MHz" step="0.0001"
|
||||
style="flex: 1; font-size: 11px; padding: 4px 6px;">
|
||||
<button class="preset-btn" onclick="addDmrBookmark()" style="font-size: 10px; padding: 4px 8px;"
|
||||
title="Add bookmark">+</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
|
||||
<input type="text" id="dmrBookmarkLabel" placeholder="Label (optional)"
|
||||
style="flex: 1; font-size: 11px; padding: 4px 6px;">
|
||||
<button class="preset-btn" onclick="addCurrentDmrFreqBookmark()" style="font-size: 9px; padding: 4px 6px;"
|
||||
title="Save current frequency">Save current</button>
|
||||
</div>
|
||||
<div id="dmrBookmarksList" style="max-height: 150px; overflow-y: auto;">
|
||||
<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Call -->
|
||||
<div class="section" style="margin-top: 12px;">
|
||||
<h3>Current Call</h3>
|
||||
<div id="dmrCurrentCall" style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
|
||||
<div style="color: var(--text-muted); text-align: center;">No active call</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="section" style="margin-top: 12px;">
|
||||
<h3>Status</h3>
|
||||
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
|
||||
<span id="dmrStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Protocol</span>
|
||||
<span id="dmrActiveProtocol" style="font-size: 11px; color: var(--text-primary);">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Calls</span>
|
||||
<span id="dmrCallCount" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mode-actions-bottom">
|
||||
<button class="run-btn" id="startDmrBtn" onclick="startDmr()">
|
||||
Start Decoder
|
||||
</button>
|
||||
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none;">
|
||||
Stop Decoder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,152 @@
|
||||
<!-- DRONE OPS MODE -->
|
||||
<div id="droneOpsMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Drone Ops Status</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="droneops-status-grid" id="droneOpsStatusGrid">
|
||||
<div class="droneops-status-card">
|
||||
<div class="droneops-status-label">Session</div>
|
||||
<div class="droneops-status-value" id="droneOpsSessionValue">Idle</div>
|
||||
</div>
|
||||
<div class="droneops-status-card">
|
||||
<div class="droneops-status-label">Armed</div>
|
||||
<div class="droneops-status-value" id="droneOpsArmedValue">No</div>
|
||||
</div>
|
||||
<div class="droneops-status-card">
|
||||
<div class="droneops-status-label">Detections</div>
|
||||
<div class="droneops-status-value" id="droneOpsDetectionCount">0</div>
|
||||
</div>
|
||||
<div class="droneops-status-card">
|
||||
<div class="droneops-status-label">Open Incidents</div>
|
||||
<div class="droneops-status-value" id="droneOpsIncidentCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="droneops-row droneops-row--actions">
|
||||
<button class="preset-btn" onclick="DroneOps.startSession('passive')">Start Passive Session</button>
|
||||
<button class="preset-btn" onclick="DroneOps.startSession('active')">Start Active Session</button>
|
||||
<button class="clear-btn" onclick="DroneOps.stopSession()">Stop Session</button>
|
||||
</div>
|
||||
|
||||
<div class="droneops-row droneops-row--actions">
|
||||
<label class="droneops-check">
|
||||
<input id="droneOpsDetectWifi" type="checkbox" checked>
|
||||
<span>WiFi</span>
|
||||
</label>
|
||||
<label class="droneops-check">
|
||||
<input id="droneOpsDetectBluetooth" type="checkbox" checked>
|
||||
<span>Bluetooth</span>
|
||||
</label>
|
||||
<button class="preset-btn" onclick="DroneOps.startDetection()">Start Detection</button>
|
||||
<button class="clear-btn" onclick="DroneOps.stopDetection()">Stop Detection</button>
|
||||
</div>
|
||||
|
||||
<div class="droneops-source-block">
|
||||
<div class="droneops-source-title">Detection Sources</div>
|
||||
<div class="droneops-row droneops-row--sources">
|
||||
<label class="droneops-field">
|
||||
<span class="droneops-field-label">WiFi Interface</span>
|
||||
<select id="droneOpsWifiInterfaceSelect" class="droneops-input" title="WiFi source interface">
|
||||
<option value="">Auto WiFi source</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="droneops-field">
|
||||
<span class="droneops-field-label">Bluetooth Adapter</span>
|
||||
<select id="droneOpsBtAdapterSelect" class="droneops-input" title="Bluetooth source adapter">
|
||||
<option value="">Auto Bluetooth source</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="preset-btn droneops-source-refresh" onclick="DroneOps.refreshDetectionSources()">Refresh Sources</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="droneops-subtext">Sensors: <span id="droneOpsSensorsState">Idle</span></div>
|
||||
|
||||
<div class="droneops-row droneops-row--actions">
|
||||
<input id="droneOpsArmIncident" class="droneops-input" type="number" placeholder="Incident ID" min="1">
|
||||
<input id="droneOpsArmReason" class="droneops-input" type="text" placeholder="Arming reason">
|
||||
<button class="preset-btn" onclick="DroneOps.arm()">Arm Actions</button>
|
||||
<button class="clear-btn" onclick="DroneOps.disarm()">Disarm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Detections</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="droneops-row">
|
||||
<select id="droneOpsSourceFilter" class="droneops-input" onchange="DroneOps.refreshDetections()">
|
||||
<option value="">All Sources</option>
|
||||
<option value="wifi">WiFi</option>
|
||||
<option value="bluetooth">Bluetooth</option>
|
||||
<option value="rf">RF</option>
|
||||
</select>
|
||||
<input id="droneOpsConfidenceFilter" class="droneops-input" type="number" min="0" max="1" step="0.05" value="0.5" placeholder="Min confidence">
|
||||
<button class="preset-btn" onclick="DroneOps.refreshDetections()">Refresh</button>
|
||||
</div>
|
||||
<div id="droneOpsDetections" class="droneops-list">
|
||||
<div class="droneops-empty">No detections yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Incidents</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="droneops-row">
|
||||
<input id="droneOpsIncidentTitle" class="droneops-input" type="text" placeholder="Incident title">
|
||||
<select id="droneOpsIncidentSeverity" class="droneops-input">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium" selected>Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
<button class="preset-btn" onclick="DroneOps.createIncident()">Create Incident</button>
|
||||
</div>
|
||||
<div id="droneOpsIncidents" class="droneops-list">
|
||||
<div class="droneops-empty">No incidents</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Actions</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="droneops-row">
|
||||
<input id="droneOpsActionIncident" class="droneops-input" type="number" placeholder="Incident ID" min="1">
|
||||
<input id="droneOpsActionType" class="droneops-input" type="text" placeholder="Action type (e.g. wifi_deauth_test)">
|
||||
<button class="preset-btn" onclick="DroneOps.requestAction()">Request Action</button>
|
||||
</div>
|
||||
<div id="droneOpsActions" class="droneops-list">
|
||||
<div class="droneops-empty">No action requests</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Evidence</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="droneops-row">
|
||||
<input id="droneOpsManifestIncident" class="droneops-input" type="number" placeholder="Incident ID" min="1">
|
||||
<button class="preset-btn" onclick="DroneOps.generateManifest()">Generate Manifest</button>
|
||||
</div>
|
||||
<div id="droneOpsManifests" class="droneops-list">
|
||||
<div class="droneops-empty">No manifests</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,6 +133,7 @@
|
||||
|
||||
<div class="mode-nav-dropdown-menu">
|
||||
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||
{{ mode_item('droneops', 'Drone Ops', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="8" r="2"/><circle cx="5" cy="16" r="2"/><circle cx="19" cy="16" r="2"/><path d="M9 9 7 8"/><path d="m15 9 2-1"/><path d="m9 15-2 1"/><path d="m15 15 2 1"/></svg>') }}
|
||||
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
||||
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
@@ -215,6 +216,7 @@
|
||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||
{# Intel #}
|
||||
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||
{{ mobile_item('droneops', 'Drone Ops', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="8" r="2"/><circle cx="5" cy="16" r="2"/><circle cx="19" cy="16" r="2"/></svg>') }}
|
||||
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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."""
|
||||
|
||||
|
||||
+23
-24
@@ -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'] = {}
|
||||
|
||||
|
||||
@@ -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
|
||||
+985
-15
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
]
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
+4
-20
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user