mirror of
https://github.com/smittix/intercept.git
synced 2026-06-15 00:53:37 -07:00
Add drone ops mode and retire DMR support
This commit is contained in:
+23
-24
@@ -122,14 +122,14 @@ def _get_mode_counts() -> dict[str, int]:
|
||||
except Exception:
|
||||
counts['aprs'] = 0
|
||||
|
||||
# Meshtastic recent messages (route-level list)
|
||||
try:
|
||||
import routes.meshtastic as mesh_route
|
||||
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
|
||||
except Exception:
|
||||
counts['meshtastic'] = 0
|
||||
|
||||
return counts
|
||||
# Meshtastic recent messages (route-level list)
|
||||
try:
|
||||
import routes.meshtastic as mesh_route
|
||||
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
|
||||
except Exception:
|
||||
counts['meshtastic'] = 0
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def get_cross_mode_summary() -> dict[str, Any]:
|
||||
@@ -160,12 +160,11 @@ def get_mode_health() -> dict[str, dict]:
|
||||
'acars': 'acars_process',
|
||||
'vdl2': 'vdl2_process',
|
||||
'aprs': 'aprs_process',
|
||||
'wifi': 'wifi_process',
|
||||
'bluetooth': 'bt_process',
|
||||
'dsc': 'dsc_process',
|
||||
'rtlamr': 'rtlamr_process',
|
||||
'dmr': 'dmr_process',
|
||||
}
|
||||
'wifi': 'wifi_process',
|
||||
'bluetooth': 'bt_process',
|
||||
'dsc': 'dsc_process',
|
||||
'rtlamr': 'rtlamr_process',
|
||||
}
|
||||
|
||||
for mode, attr in process_map.items():
|
||||
proc = getattr(app_module, attr, None)
|
||||
@@ -187,16 +186,16 @@ def get_mode_health() -> dict[str, dict]:
|
||||
pass
|
||||
|
||||
# Meshtastic: check client connection status
|
||||
try:
|
||||
from utils.meshtastic import get_meshtastic_client
|
||||
client = get_meshtastic_client()
|
||||
health['meshtastic'] = {'running': client._interface is not None}
|
||||
except Exception:
|
||||
health['meshtastic'] = {'running': False}
|
||||
|
||||
try:
|
||||
sdr_status = app_module.get_sdr_device_status()
|
||||
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
|
||||
try:
|
||||
from utils.meshtastic import get_meshtastic_client
|
||||
client = get_meshtastic_client()
|
||||
health['meshtastic'] = {'running': client._interface is not None}
|
||||
except Exception:
|
||||
health['meshtastic'] = {'running': False}
|
||||
|
||||
try:
|
||||
sdr_status = app_module.get_sdr_device_status()
|
||||
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
|
||||
except Exception:
|
||||
health['sdr_devices'] = {}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Authorization helpers for role-based and arming-gated operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import jsonify, session
|
||||
|
||||
ROLE_LEVELS: dict[str, int] = {
|
||||
'viewer': 10,
|
||||
'analyst': 20,
|
||||
'operator': 30,
|
||||
'supervisor': 40,
|
||||
'admin': 50,
|
||||
}
|
||||
|
||||
|
||||
def current_username() -> str:
|
||||
"""Get current username from session."""
|
||||
return str(session.get('username') or 'anonymous')
|
||||
|
||||
|
||||
def current_role() -> str:
|
||||
"""Get current role from session with safe default."""
|
||||
role = str(session.get('role') or 'viewer').strip().lower()
|
||||
return role if role in ROLE_LEVELS else 'viewer'
|
||||
|
||||
|
||||
def has_role(required_role: str) -> bool:
|
||||
"""Return True if current session role satisfies required role."""
|
||||
required = ROLE_LEVELS.get(required_role, ROLE_LEVELS['admin'])
|
||||
actual = ROLE_LEVELS.get(current_role(), ROLE_LEVELS['viewer'])
|
||||
return actual >= required
|
||||
|
||||
|
||||
def require_role(required_role: str) -> Callable:
|
||||
"""Decorator enforcing minimum role."""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any):
|
||||
if not has_role(required_role):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'{required_role} role required',
|
||||
'required_role': required_role,
|
||||
'current_role': current_role(),
|
||||
}), 403
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_armed(func: Callable) -> Callable:
|
||||
"""Decorator enforcing armed state for active actions."""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any):
|
||||
from utils.drone import get_drone_ops_service
|
||||
|
||||
service = get_drone_ops_service()
|
||||
policy = service.get_policy_state()
|
||||
if not policy.get('armed'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Action plane is not armed',
|
||||
'policy': policy,
|
||||
}), 403
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
+985
-15
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
"""Drone Ops utility package."""
|
||||
|
||||
from .service import DroneOpsService, get_drone_ops_service
|
||||
from .remote_id import decode_remote_id_payload
|
||||
|
||||
__all__ = [
|
||||
'DroneOpsService',
|
||||
'get_drone_ops_service',
|
||||
'decode_remote_id_payload',
|
||||
]
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Heuristics for identifying drone-related emissions across WiFi/BLE/RF feeds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from utils.drone.remote_id import decode_remote_id_payload
|
||||
|
||||
SSID_PATTERNS = [
|
||||
re.compile(r'(^|[-_\s])(dji|mavic|phantom|inspire|matrice|mini)([-_\s]|$)', re.IGNORECASE),
|
||||
re.compile(r'(^|[-_\s])(parrot|anafi|bebop)([-_\s]|$)', re.IGNORECASE),
|
||||
re.compile(r'(^|[-_\s])(autel|evo)([-_\s]|$)', re.IGNORECASE),
|
||||
re.compile(r'(^|[-_\s])(skydio|yuneec)([-_\s]|$)', re.IGNORECASE),
|
||||
re.compile(r'(^|[-_\s])(uas|uav|drone|rid|opendroneid)([-_\s]|$)', re.IGNORECASE),
|
||||
]
|
||||
|
||||
DRONE_OUI_PREFIXES = {
|
||||
'60:60:1F': 'DJI',
|
||||
'90:3A:E6': 'DJI',
|
||||
'34:D2:62': 'DJI',
|
||||
'90:3A:AF': 'DJI',
|
||||
'00:12:1C': 'Parrot',
|
||||
'90:03:B7': 'Parrot',
|
||||
'48:1C:B9': 'Autel',
|
||||
'AC:89:95': 'Skydio',
|
||||
}
|
||||
|
||||
BT_NAME_PATTERNS = [
|
||||
re.compile(r'(dji|mavic|phantom|inspire|matrice|mini)', re.IGNORECASE),
|
||||
re.compile(r'(parrot|anafi|bebop)', re.IGNORECASE),
|
||||
re.compile(r'(autel|evo)', re.IGNORECASE),
|
||||
re.compile(r'(skydio|yuneec)', re.IGNORECASE),
|
||||
re.compile(r'(remote\s?id|opendroneid|uas|uav|drone)', re.IGNORECASE),
|
||||
]
|
||||
|
||||
REMOTE_ID_UUID_HINTS = {'fffa', 'faff', 'fffb'}
|
||||
RF_FREQ_HINTS_MHZ = (315.0, 433.92, 868.0, 915.0, 1200.0, 2400.0, 5800.0)
|
||||
|
||||
|
||||
def _normalize_mac(value: Any) -> str:
|
||||
text = str(value or '').strip().upper().replace('-', ':')
|
||||
if len(text) >= 8:
|
||||
return text
|
||||
return ''
|
||||
|
||||
|
||||
def _extract_wifi_event(event: dict) -> dict | None:
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
if isinstance(event.get('network'), dict):
|
||||
return event['network']
|
||||
if event.get('type') == 'network_update' and isinstance(event.get('network'), dict):
|
||||
return event['network']
|
||||
if any(k in event for k in ('bssid', 'essid', 'ssid')):
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def _extract_bt_event(event: dict) -> dict | None:
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
if isinstance(event.get('device'), dict):
|
||||
return event['device']
|
||||
if any(k in event for k in ('device_id', 'address', 'name', 'manufacturer_name', 'service_uuids')):
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def _extract_frequency_mhz(event: dict) -> float | None:
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
event.get('frequency_mhz'),
|
||||
event.get('frequency'),
|
||||
]
|
||||
|
||||
if 'frequency_hz' in event:
|
||||
try:
|
||||
candidates.append(float(event['frequency_hz']) / 1_000_000.0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
for value in candidates:
|
||||
try:
|
||||
if value is None:
|
||||
continue
|
||||
f = float(value)
|
||||
if f > 100000: # likely in Hz
|
||||
f = f / 1_000_000.0
|
||||
if 1.0 <= f <= 7000.0:
|
||||
return round(f, 6)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
text = str(event.get('text') or event.get('message') or '')
|
||||
match = re.search(r'([0-9]{2,4}(?:\.[0-9]+)?)\s*MHz', text, flags=re.IGNORECASE)
|
||||
if match:
|
||||
try:
|
||||
return float(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _closest_freq_delta(freq_mhz: float) -> float:
|
||||
return min(abs(freq_mhz - hint) for hint in RF_FREQ_HINTS_MHZ)
|
||||
|
||||
|
||||
def _maybe_track_from_remote_id(remote_id: dict, source: str) -> dict | None:
|
||||
if not remote_id.get('detected'):
|
||||
return None
|
||||
lat = remote_id.get('lat')
|
||||
lon = remote_id.get('lon')
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
return {
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'altitude_m': remote_id.get('altitude_m'),
|
||||
'speed_mps': remote_id.get('speed_mps'),
|
||||
'heading_deg': remote_id.get('heading_deg'),
|
||||
'quality': remote_id.get('confidence', 0.0),
|
||||
'source': source,
|
||||
}
|
||||
|
||||
|
||||
def _detect_wifi(event: dict) -> list[dict]:
|
||||
network = _extract_wifi_event(event)
|
||||
if not network:
|
||||
return []
|
||||
|
||||
bssid = _normalize_mac(network.get('bssid') or network.get('mac') or network.get('id'))
|
||||
ssid = str(network.get('essid') or network.get('ssid') or network.get('display_name') or '').strip()
|
||||
identifier = bssid or ssid
|
||||
if not identifier:
|
||||
return []
|
||||
|
||||
score = 0.0
|
||||
reasons: list[str] = []
|
||||
|
||||
if ssid:
|
||||
for pattern in SSID_PATTERNS:
|
||||
if pattern.search(ssid):
|
||||
score += 0.45
|
||||
reasons.append('ssid_pattern')
|
||||
break
|
||||
|
||||
if bssid and len(bssid) >= 8:
|
||||
prefix = bssid[:8]
|
||||
if prefix in DRONE_OUI_PREFIXES:
|
||||
score += 0.45
|
||||
reasons.append(f'known_oui:{DRONE_OUI_PREFIXES[prefix]}')
|
||||
|
||||
remote_id = decode_remote_id_payload(network)
|
||||
if remote_id.get('detected'):
|
||||
score = max(score, 0.75)
|
||||
reasons.append('remote_id_payload')
|
||||
|
||||
if score < 0.5:
|
||||
return []
|
||||
|
||||
confidence = min(1.0, round(score, 3))
|
||||
classification = 'wifi_drone_remote_id' if remote_id.get('detected') else 'wifi_drone_signature'
|
||||
|
||||
return [{
|
||||
'source': 'wifi',
|
||||
'identifier': identifier,
|
||||
'classification': classification,
|
||||
'confidence': confidence,
|
||||
'payload': {
|
||||
'network': network,
|
||||
'reasons': reasons,
|
||||
'brand_hint': DRONE_OUI_PREFIXES.get(bssid[:8]) if bssid else None,
|
||||
},
|
||||
'remote_id': remote_id if remote_id.get('detected') else None,
|
||||
'track': _maybe_track_from_remote_id(remote_id, 'wifi'),
|
||||
}]
|
||||
|
||||
|
||||
def _detect_bluetooth(event: dict) -> list[dict]:
|
||||
device = _extract_bt_event(event)
|
||||
if not device:
|
||||
return []
|
||||
|
||||
address = _normalize_mac(device.get('address') or device.get('mac'))
|
||||
device_id = str(device.get('device_id') or '').strip()
|
||||
name = str(device.get('name') or '').strip()
|
||||
manufacturer = str(device.get('manufacturer_name') or '').strip()
|
||||
identifier = address or device_id or name
|
||||
if not identifier:
|
||||
return []
|
||||
|
||||
score = 0.0
|
||||
reasons: list[str] = []
|
||||
|
||||
haystack = f'{name} {manufacturer}'.strip()
|
||||
if haystack:
|
||||
for pattern in BT_NAME_PATTERNS:
|
||||
if pattern.search(haystack):
|
||||
score += 0.55
|
||||
reasons.append('name_or_vendor_pattern')
|
||||
break
|
||||
|
||||
uuids = device.get('service_uuids') or []
|
||||
for uuid in uuids:
|
||||
if str(uuid).replace('-', '').lower()[-4:] in REMOTE_ID_UUID_HINTS:
|
||||
score = max(score, 0.7)
|
||||
reasons.append('remote_id_service_uuid')
|
||||
break
|
||||
|
||||
tracker = device.get('tracker') if isinstance(device.get('tracker'), dict) else {}
|
||||
if tracker.get('is_tracker') and 'drone' in str(tracker.get('type') or '').lower():
|
||||
score = max(score, 0.7)
|
||||
reasons.append('tracker_engine_drone_label')
|
||||
|
||||
remote_id = decode_remote_id_payload(device)
|
||||
if remote_id.get('detected'):
|
||||
score = max(score, 0.75)
|
||||
reasons.append('remote_id_payload')
|
||||
|
||||
if score < 0.55:
|
||||
return []
|
||||
|
||||
confidence = min(1.0, round(score, 3))
|
||||
classification = 'bluetooth_drone_remote_id' if remote_id.get('detected') else 'bluetooth_drone_signature'
|
||||
|
||||
return [{
|
||||
'source': 'bluetooth',
|
||||
'identifier': identifier,
|
||||
'classification': classification,
|
||||
'confidence': confidence,
|
||||
'payload': {
|
||||
'device': device,
|
||||
'reasons': reasons,
|
||||
},
|
||||
'remote_id': remote_id if remote_id.get('detected') else None,
|
||||
'track': _maybe_track_from_remote_id(remote_id, 'bluetooth'),
|
||||
}]
|
||||
|
||||
|
||||
def _detect_rf(event: dict) -> list[dict]:
|
||||
if not isinstance(event, dict):
|
||||
return []
|
||||
|
||||
freq_mhz = _extract_frequency_mhz(event)
|
||||
if freq_mhz is None:
|
||||
return []
|
||||
|
||||
delta = _closest_freq_delta(freq_mhz)
|
||||
if delta > 35.0:
|
||||
return []
|
||||
|
||||
score = max(0.5, 0.85 - (delta / 100.0))
|
||||
confidence = min(1.0, round(score, 3))
|
||||
|
||||
event_id = str(event.get('capture_id') or event.get('id') or f'{freq_mhz:.3f}MHz')
|
||||
identifier = f'rf:{event_id}'
|
||||
|
||||
payload = {
|
||||
'event': event,
|
||||
'frequency_mhz': freq_mhz,
|
||||
'delta_from_known_band_mhz': round(delta, 3),
|
||||
'known_bands_mhz': list(RF_FREQ_HINTS_MHZ),
|
||||
}
|
||||
|
||||
return [{
|
||||
'source': 'rf',
|
||||
'identifier': identifier,
|
||||
'classification': 'rf_drone_link_activity',
|
||||
'confidence': confidence,
|
||||
'payload': payload,
|
||||
'remote_id': None,
|
||||
'track': None,
|
||||
}]
|
||||
|
||||
|
||||
def detect_from_event(mode: str, event: dict, event_type: str | None = None) -> list[dict]:
|
||||
"""Detect drone-relevant signals from a normalized mode event."""
|
||||
mode_lower = str(mode or '').lower()
|
||||
|
||||
if mode_lower.startswith('wifi'):
|
||||
return _detect_wifi(event)
|
||||
if mode_lower.startswith('bluetooth') or mode_lower.startswith('bt'):
|
||||
return _detect_bluetooth(event)
|
||||
if mode_lower in {'subghz', 'listening_scanner', 'waterfall', 'listening'}:
|
||||
return _detect_rf(event)
|
||||
|
||||
# Opportunistic decode from any feed that carries explicit remote ID payloads.
|
||||
remote_id = decode_remote_id_payload(event)
|
||||
if remote_id.get('detected'):
|
||||
identifier = str(remote_id.get('uas_id') or remote_id.get('operator_id') or 'remote_id')
|
||||
return [{
|
||||
'source': mode_lower or 'unknown',
|
||||
'identifier': identifier,
|
||||
'classification': 'remote_id_detected',
|
||||
'confidence': float(remote_id.get('confidence') or 0.6),
|
||||
'payload': {'event': event, 'event_type': event_type},
|
||||
'remote_id': remote_id,
|
||||
'track': _maybe_track_from_remote_id(remote_id, mode_lower or 'unknown'),
|
||||
}]
|
||||
|
||||
return []
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Drone Ops policy helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def required_approvals_for_action(action_type: str) -> int:
|
||||
"""Return required approvals for a given action type."""
|
||||
action = (action_type or '').strip().lower()
|
||||
if action.startswith('passive_'):
|
||||
return 1
|
||||
return 2
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Remote ID payload normalization and lightweight decoding helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
DRONE_ID_KEYS = ('uas_id', 'drone_id', 'serial_number', 'serial', 'id', 'uasId')
|
||||
OPERATOR_ID_KEYS = ('operator_id', 'pilot_id', 'operator', 'operatorId')
|
||||
LAT_KEYS = ('lat', 'latitude')
|
||||
LON_KEYS = ('lon', 'lng', 'longitude')
|
||||
ALT_KEYS = ('alt', 'altitude', 'altitude_m', 'height')
|
||||
SPEED_KEYS = ('speed', 'speed_mps', 'ground_speed')
|
||||
HEADING_KEYS = ('heading', 'heading_deg', 'course')
|
||||
|
||||
|
||||
def _get_nested(data: dict, *paths: str) -> Any:
|
||||
for path in paths:
|
||||
current: Any = data
|
||||
valid = True
|
||||
for part in path.split('.'):
|
||||
if not isinstance(current, dict) or part not in current:
|
||||
valid = False
|
||||
break
|
||||
current = current[part]
|
||||
if valid:
|
||||
return current
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> float | None:
|
||||
try:
|
||||
if value is None or value == '':
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _pick(data: dict, keys: tuple[str, ...], nested_prefixes: tuple[str, ...] = ()) -> Any:
|
||||
for key in keys:
|
||||
if key in data:
|
||||
return data.get(key)
|
||||
for prefix in nested_prefixes:
|
||||
for key in keys:
|
||||
hit = _get_nested(data, f'{prefix}.{key}')
|
||||
if hit is not None:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_input(payload: Any) -> tuple[dict, str]:
|
||||
if isinstance(payload, dict):
|
||||
return payload, 'dict'
|
||||
|
||||
if isinstance(payload, bytes):
|
||||
text = payload.decode('utf-8', errors='replace').strip()
|
||||
else:
|
||||
text = str(payload or '').strip()
|
||||
|
||||
if not text:
|
||||
return {}, 'empty'
|
||||
|
||||
# JSON-first parsing.
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, 'json'
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Keep opaque string payload available to caller.
|
||||
return {'raw': text}, 'raw'
|
||||
|
||||
|
||||
def decode_remote_id_payload(payload: Any) -> dict:
|
||||
"""Decode/normalize Remote ID-like payload into a common shape."""
|
||||
data, fmt = _normalize_input(payload)
|
||||
|
||||
drone_id = _pick(data, DRONE_ID_KEYS, ('remote_id', 'message', 'uas'))
|
||||
operator_id = _pick(data, OPERATOR_ID_KEYS, ('remote_id', 'message', 'operator'))
|
||||
|
||||
lat = _coerce_float(_pick(data, LAT_KEYS, ('remote_id', 'message', 'position')))
|
||||
lon = _coerce_float(_pick(data, LON_KEYS, ('remote_id', 'message', 'position')))
|
||||
altitude_m = _coerce_float(_pick(data, ALT_KEYS, ('remote_id', 'message', 'position')))
|
||||
speed_mps = _coerce_float(_pick(data, SPEED_KEYS, ('remote_id', 'message', 'position')))
|
||||
heading_deg = _coerce_float(_pick(data, HEADING_KEYS, ('remote_id', 'message', 'position')))
|
||||
|
||||
confidence = 0.0
|
||||
if drone_id:
|
||||
confidence += 0.35
|
||||
if lat is not None and lon is not None:
|
||||
confidence += 0.35
|
||||
if altitude_m is not None:
|
||||
confidence += 0.15
|
||||
if operator_id:
|
||||
confidence += 0.15
|
||||
confidence = min(1.0, round(confidence, 3))
|
||||
|
||||
detected = bool(drone_id or (lat is not None and lon is not None and confidence >= 0.35))
|
||||
|
||||
normalized = {
|
||||
'detected': detected,
|
||||
'source_format': fmt,
|
||||
'uas_id': str(drone_id).strip() if drone_id else None,
|
||||
'operator_id': str(operator_id).strip() if operator_id else None,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'altitude_m': altitude_m,
|
||||
'speed_mps': speed_mps,
|
||||
'heading_deg': heading_deg,
|
||||
'confidence': confidence,
|
||||
'raw': data,
|
||||
}
|
||||
|
||||
# Remove heavy raw payload if we successfully extracted structure.
|
||||
if detected and isinstance(data, dict) and len(data) > 0:
|
||||
normalized['raw'] = data
|
||||
|
||||
return normalized
|
||||
@@ -0,0 +1,621 @@
|
||||
"""Stateful Drone Ops service: ingestion, policy, incidents, actions, and evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Generator
|
||||
|
||||
import app as app_module
|
||||
|
||||
from utils.correlation import get_correlations as get_wifi_bt_correlations
|
||||
from utils.database import (
|
||||
add_action_approval,
|
||||
add_action_audit_log,
|
||||
add_drone_correlation,
|
||||
add_drone_incident_artifact,
|
||||
add_drone_track,
|
||||
create_action_request,
|
||||
create_drone_incident,
|
||||
create_drone_session,
|
||||
create_evidence_manifest,
|
||||
get_action_request,
|
||||
get_active_drone_session,
|
||||
get_drone_detection,
|
||||
get_drone_incident,
|
||||
get_drone_session,
|
||||
get_evidence_manifest,
|
||||
list_action_audit_logs,
|
||||
list_action_requests,
|
||||
list_drone_correlations,
|
||||
list_drone_detections,
|
||||
list_drone_incidents,
|
||||
list_drone_sessions,
|
||||
list_drone_tracks,
|
||||
list_evidence_manifests,
|
||||
stop_drone_session,
|
||||
update_action_request_status,
|
||||
update_drone_incident,
|
||||
upsert_drone_detection,
|
||||
)
|
||||
from utils.drone.detector import detect_from_event
|
||||
from utils.drone.remote_id import decode_remote_id_payload
|
||||
from utils.trilateration import estimate_location_from_observations
|
||||
|
||||
|
||||
class DroneOpsService:
|
||||
"""Orchestrates Drone Ops data and policy controls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: set[queue.Queue] = set()
|
||||
self._subs_lock = threading.Lock()
|
||||
|
||||
self._policy_lock = threading.Lock()
|
||||
self._armed_until_ts: float | None = None
|
||||
self._armed_by: str | None = None
|
||||
self._arm_reason: str | None = None
|
||||
self._arm_incident_id: int | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Streaming
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _emit(self, event_type: str, payload: dict) -> None:
|
||||
envelope = {
|
||||
'type': event_type,
|
||||
'timestamp': self._utc_now_iso(),
|
||||
'payload': payload,
|
||||
}
|
||||
with self._subs_lock:
|
||||
subscribers = tuple(self._subscribers)
|
||||
|
||||
for sub in subscribers:
|
||||
try:
|
||||
sub.put_nowait(envelope)
|
||||
except queue.Full:
|
||||
try:
|
||||
sub.get_nowait()
|
||||
sub.put_nowait(envelope)
|
||||
except (queue.Empty, queue.Full):
|
||||
continue
|
||||
|
||||
def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]:
|
||||
"""Yield Drone Ops events for SSE streaming."""
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=500)
|
||||
|
||||
with self._subs_lock:
|
||||
self._subscribers.add(client_queue)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
yield client_queue.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
yield {'type': 'keepalive', 'timestamp': self._utc_now_iso(), 'payload': {}}
|
||||
finally:
|
||||
with self._subs_lock:
|
||||
self._subscribers.discard(client_queue)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Policy / arming
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _policy_state_locked(self) -> dict:
|
||||
armed = self._armed_until_ts is not None and time.time() < self._armed_until_ts
|
||||
if not armed:
|
||||
self._armed_until_ts = None
|
||||
self._armed_by = None
|
||||
self._arm_reason = None
|
||||
self._arm_incident_id = None
|
||||
|
||||
return {
|
||||
'armed': armed,
|
||||
'armed_by': self._armed_by,
|
||||
'arm_reason': self._arm_reason,
|
||||
'arm_incident_id': self._arm_incident_id,
|
||||
'armed_until': datetime.fromtimestamp(self._armed_until_ts, tz=timezone.utc).isoformat() if self._armed_until_ts else None,
|
||||
'required_approvals_default': 2,
|
||||
}
|
||||
|
||||
def get_policy_state(self) -> dict:
|
||||
"""Get current policy and arming state."""
|
||||
with self._policy_lock:
|
||||
return self._policy_state_locked()
|
||||
|
||||
def arm_actions(
|
||||
self,
|
||||
actor: str,
|
||||
reason: str,
|
||||
incident_id: int,
|
||||
duration_seconds: int = 900,
|
||||
) -> dict:
|
||||
"""Arm action plane for a bounded duration."""
|
||||
duration_seconds = max(60, min(7200, int(duration_seconds or 900)))
|
||||
with self._policy_lock:
|
||||
self._armed_until_ts = time.time() + duration_seconds
|
||||
self._armed_by = actor
|
||||
self._arm_reason = reason
|
||||
self._arm_incident_id = incident_id
|
||||
state = self._policy_state_locked()
|
||||
|
||||
self._emit('policy_armed', {'actor': actor, 'reason': reason, 'incident_id': incident_id, 'state': state})
|
||||
return state
|
||||
|
||||
def disarm_actions(self, actor: str, reason: str | None = None) -> dict:
|
||||
"""Disarm action plane."""
|
||||
with self._policy_lock:
|
||||
self._armed_until_ts = None
|
||||
self._armed_by = None
|
||||
self._arm_reason = None
|
||||
self._arm_incident_id = None
|
||||
state = self._policy_state_locked()
|
||||
|
||||
self._emit('policy_disarmed', {'actor': actor, 'reason': reason, 'state': state})
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def required_approvals(action_type: str) -> int:
|
||||
"""Compute required approvals for an action type."""
|
||||
action = (action_type or '').strip().lower()
|
||||
if action.startswith('passive_'):
|
||||
return 1
|
||||
return 2
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sessions and detections
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start_session(
|
||||
self,
|
||||
mode: str,
|
||||
label: str | None,
|
||||
operator: str,
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
"""Start a Drone Ops session."""
|
||||
active = get_active_drone_session()
|
||||
if active:
|
||||
return active
|
||||
|
||||
session_id = create_drone_session(
|
||||
mode=mode or 'passive',
|
||||
label=label,
|
||||
operator=operator,
|
||||
metadata=metadata,
|
||||
)
|
||||
session = get_drone_session(session_id)
|
||||
if session:
|
||||
self._emit('session_started', {'session': session})
|
||||
return session or {}
|
||||
|
||||
def stop_session(
|
||||
self,
|
||||
operator: str,
|
||||
session_id: int | None = None,
|
||||
summary: dict | None = None,
|
||||
) -> dict | None:
|
||||
"""Stop a Drone Ops session."""
|
||||
active = get_active_drone_session()
|
||||
target_id = session_id or (active['id'] if active else None)
|
||||
if not target_id:
|
||||
return None
|
||||
|
||||
if summary is None:
|
||||
summary = {
|
||||
'operator': operator,
|
||||
'stopped_at': self._utc_now_iso(),
|
||||
'detections': len(list_drone_detections(session_id=target_id, limit=1000)),
|
||||
}
|
||||
|
||||
stop_drone_session(target_id, summary=summary)
|
||||
session = get_drone_session(target_id)
|
||||
if session:
|
||||
self._emit('session_stopped', {'session': session})
|
||||
return session
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get full Drone Ops status payload."""
|
||||
return {
|
||||
'status': 'success',
|
||||
'active_session': get_active_drone_session(),
|
||||
'policy': self.get_policy_state(),
|
||||
'counts': {
|
||||
'detections': len(list_drone_detections(limit=1000)),
|
||||
'incidents_open': len(list_drone_incidents(status='open', limit=1000)),
|
||||
'actions_pending': len(list_action_requests(status='pending', limit=1000)),
|
||||
},
|
||||
}
|
||||
|
||||
def ingest_event(self, mode: str, event: dict, event_type: str | None = None) -> None:
|
||||
"""Ingest cross-mode event and produce Drone Ops detections."""
|
||||
try:
|
||||
detections = detect_from_event(mode, event, event_type)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not detections:
|
||||
return
|
||||
|
||||
active = get_active_drone_session()
|
||||
session_id = active['id'] if active else None
|
||||
|
||||
for detection in detections:
|
||||
try:
|
||||
detection_id = upsert_drone_detection(
|
||||
session_id=session_id,
|
||||
source=detection['source'],
|
||||
identifier=detection['identifier'],
|
||||
classification=detection.get('classification'),
|
||||
confidence=float(detection.get('confidence') or 0.0),
|
||||
payload=detection.get('payload') or {},
|
||||
remote_id=detection.get('remote_id') or None,
|
||||
)
|
||||
row = get_drone_detection(detection_id)
|
||||
|
||||
track = detection.get('track') or {}
|
||||
if row and track and track.get('lat') is not None and track.get('lon') is not None:
|
||||
add_drone_track(
|
||||
detection_id=row['id'],
|
||||
lat=track.get('lat'),
|
||||
lon=track.get('lon'),
|
||||
altitude_m=track.get('altitude_m'),
|
||||
speed_mps=track.get('speed_mps'),
|
||||
heading_deg=track.get('heading_deg'),
|
||||
quality=track.get('quality'),
|
||||
source=track.get('source') or detection.get('source'),
|
||||
)
|
||||
|
||||
remote_id = detection.get('remote_id') or {}
|
||||
uas_id = remote_id.get('uas_id')
|
||||
operator_id = remote_id.get('operator_id')
|
||||
if uas_id and operator_id:
|
||||
add_drone_correlation(
|
||||
drone_identifier=str(uas_id),
|
||||
operator_identifier=str(operator_id),
|
||||
method='remote_id_binding',
|
||||
confidence=float(remote_id.get('confidence') or 0.8),
|
||||
evidence={
|
||||
'source': detection.get('source'),
|
||||
'event_type': event_type,
|
||||
'detection_id': row['id'] if row else None,
|
||||
},
|
||||
)
|
||||
|
||||
if row:
|
||||
self._emit('detection', {
|
||||
'mode': mode,
|
||||
'event_type': event_type,
|
||||
'detection': row,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def decode_remote_id(self, payload: Any) -> dict:
|
||||
"""Decode an explicit Remote ID payload."""
|
||||
decoded = decode_remote_id_payload(payload)
|
||||
self._emit('remote_id_decoded', {'decoded': decoded})
|
||||
return decoded
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_detections(
|
||||
self,
|
||||
session_id: int | None = None,
|
||||
source: str | None = None,
|
||||
min_confidence: float = 0.0,
|
||||
limit: int = 200,
|
||||
) -> list[dict]:
|
||||
return list_drone_detections(
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
min_confidence=min_confidence,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def get_tracks(
|
||||
self,
|
||||
detection_id: int | None = None,
|
||||
identifier: str | None = None,
|
||||
limit: int = 1000,
|
||||
) -> list[dict]:
|
||||
return list_drone_tracks(
|
||||
detection_id=detection_id,
|
||||
identifier=identifier,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def estimate_geolocation(self, observations: list[dict], environment: str = 'outdoor') -> dict | None:
|
||||
"""Estimate location from observations."""
|
||||
return estimate_location_from_observations(observations, environment=environment)
|
||||
|
||||
def refresh_correlations(self, min_confidence: float = 0.6) -> list[dict]:
|
||||
"""Refresh and persist likely drone/operator correlations from WiFi<->BT pairs."""
|
||||
wifi_devices = dict(app_module.wifi_networks)
|
||||
wifi_devices.update(dict(app_module.wifi_clients))
|
||||
bt_devices = dict(app_module.bt_devices)
|
||||
|
||||
pairs = get_wifi_bt_correlations(
|
||||
wifi_devices=wifi_devices,
|
||||
bt_devices=bt_devices,
|
||||
min_confidence=min_confidence,
|
||||
include_historical=True,
|
||||
)
|
||||
|
||||
detections = list_drone_detections(min_confidence=0.5, limit=1000)
|
||||
known_ids = {d['identifier'].upper() for d in detections}
|
||||
|
||||
for pair in pairs:
|
||||
wifi_mac = str(pair.get('wifi_mac') or '').upper()
|
||||
bt_mac = str(pair.get('bt_mac') or '').upper()
|
||||
if wifi_mac in known_ids or bt_mac in known_ids:
|
||||
add_drone_correlation(
|
||||
drone_identifier=wifi_mac if wifi_mac in known_ids else bt_mac,
|
||||
operator_identifier=bt_mac if wifi_mac in known_ids else wifi_mac,
|
||||
method='wifi_bt_correlation',
|
||||
confidence=float(pair.get('confidence') or 0.0),
|
||||
evidence=pair,
|
||||
)
|
||||
|
||||
return list_drone_correlations(min_confidence=min_confidence, limit=200)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Incidents and artifacts
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_incident(
|
||||
self,
|
||||
title: str,
|
||||
severity: str,
|
||||
opened_by: str,
|
||||
summary: str | None,
|
||||
metadata: dict | None,
|
||||
) -> dict:
|
||||
incident_id = create_drone_incident(
|
||||
title=title,
|
||||
severity=severity,
|
||||
opened_by=opened_by,
|
||||
summary=summary,
|
||||
metadata=metadata,
|
||||
)
|
||||
incident = get_drone_incident(incident_id) or {'id': incident_id}
|
||||
self._emit('incident_created', {'incident': incident})
|
||||
return incident
|
||||
|
||||
def update_incident(
|
||||
self,
|
||||
incident_id: int,
|
||||
status: str | None = None,
|
||||
severity: str | None = None,
|
||||
summary: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> dict | None:
|
||||
update_drone_incident(
|
||||
incident_id=incident_id,
|
||||
status=status,
|
||||
severity=severity,
|
||||
summary=summary,
|
||||
metadata=metadata,
|
||||
)
|
||||
incident = get_drone_incident(incident_id)
|
||||
if incident:
|
||||
self._emit('incident_updated', {'incident': incident})
|
||||
return incident
|
||||
|
||||
def add_incident_artifact(
|
||||
self,
|
||||
incident_id: int,
|
||||
artifact_type: str,
|
||||
artifact_ref: str,
|
||||
added_by: str,
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
artifact_id = add_drone_incident_artifact(
|
||||
incident_id=incident_id,
|
||||
artifact_type=artifact_type,
|
||||
artifact_ref=artifact_ref,
|
||||
added_by=added_by,
|
||||
metadata=metadata,
|
||||
)
|
||||
artifact = {
|
||||
'id': artifact_id,
|
||||
'incident_id': incident_id,
|
||||
'artifact_type': artifact_type,
|
||||
'artifact_ref': artifact_ref,
|
||||
'added_by': added_by,
|
||||
'metadata': metadata or {},
|
||||
}
|
||||
self._emit('incident_artifact_added', {'artifact': artifact})
|
||||
return artifact
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions and approvals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def request_action(
|
||||
self,
|
||||
incident_id: int,
|
||||
action_type: str,
|
||||
requested_by: str,
|
||||
payload: dict | None,
|
||||
) -> dict | None:
|
||||
request_id = create_action_request(
|
||||
incident_id=incident_id,
|
||||
action_type=action_type,
|
||||
requested_by=requested_by,
|
||||
payload=payload,
|
||||
)
|
||||
add_action_audit_log(
|
||||
request_id=request_id,
|
||||
event_type='requested',
|
||||
actor=requested_by,
|
||||
details={'payload': payload or {}},
|
||||
)
|
||||
req = get_action_request(request_id)
|
||||
if req:
|
||||
req['required_approvals'] = self.required_approvals(req['action_type'])
|
||||
self._emit('action_requested', {'request': req})
|
||||
return req
|
||||
|
||||
def approve_action(
|
||||
self,
|
||||
request_id: int,
|
||||
approver: str,
|
||||
decision: str = 'approved',
|
||||
notes: str | None = None,
|
||||
) -> dict | None:
|
||||
req = get_action_request(request_id)
|
||||
if not req:
|
||||
return None
|
||||
|
||||
approvals = req.get('approvals', [])
|
||||
if any((a.get('approved_by') or '').lower() == approver.lower() for a in approvals):
|
||||
return req
|
||||
|
||||
add_action_approval(request_id=request_id, approved_by=approver, decision=decision, notes=notes)
|
||||
add_action_audit_log(
|
||||
request_id=request_id,
|
||||
event_type='approval',
|
||||
actor=approver,
|
||||
details={'decision': decision, 'notes': notes},
|
||||
)
|
||||
|
||||
req = get_action_request(request_id)
|
||||
if not req:
|
||||
return None
|
||||
|
||||
approvals = req.get('approvals', [])
|
||||
approved_count = len([a for a in approvals if str(a.get('decision')).lower() == 'approved'])
|
||||
required = self.required_approvals(req['action_type'])
|
||||
|
||||
if decision.lower() == 'rejected':
|
||||
update_action_request_status(request_id, status='rejected')
|
||||
elif approved_count >= required and req.get('status') not in {'executed', 'rejected'}:
|
||||
update_action_request_status(request_id, status='approved')
|
||||
|
||||
req = get_action_request(request_id)
|
||||
if req:
|
||||
req['required_approvals'] = required
|
||||
req['approved_count'] = approved_count
|
||||
self._emit('action_approved', {'request': req})
|
||||
return req
|
||||
|
||||
def execute_action(self, request_id: int, actor: str) -> tuple[dict | None, str | None]:
|
||||
"""Execute an approved action request (policy-gated)."""
|
||||
req = get_action_request(request_id)
|
||||
if not req:
|
||||
return None, 'Action request not found'
|
||||
|
||||
policy = self.get_policy_state()
|
||||
if not policy.get('armed'):
|
||||
return None, 'Action plane is not armed'
|
||||
|
||||
approvals = req.get('approvals', [])
|
||||
approved_count = len([a for a in approvals if str(a.get('decision')).lower() == 'approved'])
|
||||
required = self.required_approvals(req['action_type'])
|
||||
|
||||
if approved_count < required:
|
||||
return None, f'Insufficient approvals ({approved_count}/{required})'
|
||||
|
||||
update_action_request_status(request_id, status='executed', executed_by=actor)
|
||||
add_action_audit_log(
|
||||
request_id=request_id,
|
||||
event_type='executed',
|
||||
actor=actor,
|
||||
details={
|
||||
'dispatch': 'framework',
|
||||
'note': 'Execution recorded. Attach route-specific handlers per action_type.',
|
||||
},
|
||||
)
|
||||
|
||||
req = get_action_request(request_id)
|
||||
if req:
|
||||
req['required_approvals'] = required
|
||||
req['approved_count'] = approved_count
|
||||
self._emit('action_executed', {'request': req})
|
||||
return req, None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Evidence and manifests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def generate_evidence_manifest(
|
||||
self,
|
||||
incident_id: int,
|
||||
created_by: str,
|
||||
signature: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Build and persist an evidence manifest for an incident."""
|
||||
incident = get_drone_incident(incident_id)
|
||||
if not incident:
|
||||
return None
|
||||
|
||||
action_requests = list_action_requests(incident_id=incident_id, limit=1000)
|
||||
request_ids = [r['id'] for r in action_requests]
|
||||
action_audit: list[dict] = []
|
||||
for request_id in request_ids:
|
||||
action_audit.extend(list_action_audit_logs(request_id=request_id, limit=500))
|
||||
|
||||
manifest_core = {
|
||||
'generated_at': self._utc_now_iso(),
|
||||
'incident': {
|
||||
'id': incident['id'],
|
||||
'title': incident['title'],
|
||||
'status': incident['status'],
|
||||
'severity': incident['severity'],
|
||||
'opened_at': incident['opened_at'],
|
||||
'closed_at': incident['closed_at'],
|
||||
},
|
||||
'artifact_count': len(incident.get('artifacts', [])),
|
||||
'action_request_count': len(action_requests),
|
||||
'audit_event_count': len(action_audit),
|
||||
'artifacts': incident.get('artifacts', []),
|
||||
'action_requests': action_requests,
|
||||
'action_audit': action_audit,
|
||||
}
|
||||
|
||||
canonical = json.dumps(manifest_core, sort_keys=True, separators=(',', ':'))
|
||||
sha256_hex = hashlib.sha256(canonical.encode('utf-8')).hexdigest()
|
||||
|
||||
manifest = {
|
||||
**manifest_core,
|
||||
'integrity': {
|
||||
'algorithm': 'sha256',
|
||||
'digest': sha256_hex,
|
||||
},
|
||||
}
|
||||
|
||||
manifest_id = create_evidence_manifest(
|
||||
incident_id=incident_id,
|
||||
manifest=manifest,
|
||||
hash_algo='sha256',
|
||||
signature=signature,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
stored = get_evidence_manifest(manifest_id)
|
||||
if stored:
|
||||
self._emit('evidence_manifest_created', {'manifest': stored})
|
||||
return stored
|
||||
|
||||
|
||||
_drone_service: DroneOpsService | None = None
|
||||
_drone_service_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_drone_ops_service() -> DroneOpsService:
|
||||
"""Get global Drone Ops service singleton."""
|
||||
global _drone_service
|
||||
with _drone_service_lock:
|
||||
if _drone_service is None:
|
||||
_drone_service = DroneOpsService()
|
||||
return _drone_service
|
||||
@@ -54,6 +54,13 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) -
|
||||
# Alert failures should never break streaming
|
||||
pass
|
||||
|
||||
try:
|
||||
from utils.drone import get_drone_ops_service
|
||||
get_drone_ops_service().ingest_event(mode, event, event_type)
|
||||
except Exception:
|
||||
# Drone ingest should never break mode streaming
|
||||
pass
|
||||
|
||||
|
||||
def _extract_device_id(event: dict) -> str | None:
|
||||
for field in DEVICE_ID_FIELDS:
|
||||
|
||||
+4
-20
@@ -343,26 +343,10 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [
|
||||
regions=["GLOBAL"],
|
||||
),
|
||||
|
||||
# LoRaWAN
|
||||
SignalTypeDefinition(
|
||||
label="LoRaWAN / LoRa Device",
|
||||
tags=["iot", "lora", "lpwan", "telemetry"],
|
||||
description="LoRa long-range IoT device",
|
||||
frequency_ranges=[
|
||||
(863_000_000, 870_000_000), # EU868
|
||||
(902_000_000, 928_000_000), # US915
|
||||
],
|
||||
modulation_hints=["LoRa", "CSS", "FSK"],
|
||||
bandwidth_range=(125_000, 500_000), # LoRa spreading bandwidths
|
||||
base_score=11,
|
||||
is_burst_type=True,
|
||||
regions=["UK/EU", "US"],
|
||||
),
|
||||
|
||||
# Key Fob / Remote
|
||||
SignalTypeDefinition(
|
||||
label="Remote Control / Key Fob",
|
||||
tags=["remote", "keyfob", "automotive", "burst", "ism"],
|
||||
# Key Fob / Remote
|
||||
SignalTypeDefinition(
|
||||
label="Remote Control / Key Fob",
|
||||
tags=["remote", "keyfob", "automotive", "burst", "ism"],
|
||||
description="Wireless remote control or vehicle key fob",
|
||||
frequency_ranges=[
|
||||
(314_900_000, 315_100_000), # 315 MHz (US)
|
||||
|
||||
Reference in New Issue
Block a user