Add drone ops mode and retire DMR support

This commit is contained in:
Smittix
2026-02-20 17:02:16 +00:00
parent 9ec316fbe2
commit b628a5f751
36 changed files with 5338 additions and 2418 deletions
-23
View File
@@ -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/*
+20 -43
View File
@@ -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})
+2 -5
View File
@@ -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",
+192
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+431
View File
@@ -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
View File
@@ -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()
+2 -121
View File
@@ -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
+12
View File
@@ -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 {
+498
View File
@@ -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;
}
}
+4 -17
View File
@@ -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'],
-1
View File
@@ -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' },
+1 -3
View File
@@ -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';
}
-852
View File
@@ -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
View File
@@ -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') {
-114
View File
@@ -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>
+152
View File
@@ -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">&#9660;</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">&#9660;</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">&#9660;</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">&#9660;</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">&#9660;</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>
+2
View File
@@ -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>') }}
-311
View File
@@ -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
+36
View File
@@ -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
+60
View File
@@ -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'
+228
View File
@@ -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'
-11
View File
@@ -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
View File
@@ -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'] = {}
+74
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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',
]
+305
View File
@@ -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 []
+11
View File
@@ -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
+121
View File
@@ -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
+621
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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)