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 -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)