mirror of
https://github.com/smittix/intercept.git
synced 2026-07-08 01:28:13 -07:00
Remove Drone Ops feature end-to-end
This commit is contained in:
@@ -1,74 +0,0 @@
|
||||
"""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
|
||||
@@ -555,172 +555,6 @@ def init_db() -> None:
|
||||
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
|
||||
''')
|
||||
|
||||
# =====================================================================
|
||||
# Drone Ops / Professional Ops Tables
|
||||
# =====================================================================
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS drone_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mode TEXT NOT NULL DEFAULT 'passive',
|
||||
label TEXT,
|
||||
operator TEXT,
|
||||
metadata TEXT,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
stopped_at TIMESTAMP,
|
||||
summary TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS drone_detections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER,
|
||||
source TEXT NOT NULL,
|
||||
identifier TEXT NOT NULL,
|
||||
classification TEXT,
|
||||
confidence REAL DEFAULT 0,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
payload_json TEXT,
|
||||
remote_id_json TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES drone_sessions(id) ON DELETE SET NULL,
|
||||
UNIQUE(session_id, source, identifier)
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS drone_tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
detection_id INTEGER NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
altitude_m REAL,
|
||||
speed_mps REAL,
|
||||
heading_deg REAL,
|
||||
quality REAL,
|
||||
source TEXT,
|
||||
FOREIGN KEY (detection_id) REFERENCES drone_detections(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS drone_correlations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drone_identifier TEXT,
|
||||
operator_identifier TEXT,
|
||||
method TEXT,
|
||||
confidence REAL,
|
||||
evidence_json TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS drone_incidents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'open',
|
||||
severity TEXT DEFAULT 'medium',
|
||||
opened_by TEXT,
|
||||
opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at TIMESTAMP,
|
||||
summary TEXT,
|
||||
metadata TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS drone_incident_artifacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
incident_id INTEGER NOT NULL,
|
||||
artifact_type TEXT NOT NULL,
|
||||
artifact_ref TEXT NOT NULL,
|
||||
added_by TEXT,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (incident_id) REFERENCES drone_incidents(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS action_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
incident_id INTEGER NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
requested_by TEXT NOT NULL,
|
||||
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'pending',
|
||||
payload_json TEXT,
|
||||
executed_at TIMESTAMP,
|
||||
executed_by TEXT,
|
||||
FOREIGN KEY (incident_id) REFERENCES drone_incidents(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS action_approvals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
request_id INTEGER NOT NULL,
|
||||
approved_by TEXT NOT NULL,
|
||||
approved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
decision TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
FOREIGN KEY (request_id) REFERENCES action_requests(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS action_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
request_id INTEGER,
|
||||
event_type TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
details_json TEXT,
|
||||
FOREIGN KEY (request_id) REFERENCES action_requests(id) ON DELETE SET NULL
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS evidence_manifests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
incident_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
hash_algo TEXT DEFAULT 'sha256',
|
||||
manifest_json TEXT NOT NULL,
|
||||
signature TEXT,
|
||||
created_by TEXT,
|
||||
FOREIGN KEY (incident_id) REFERENCES drone_incidents(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_drone_detections_last_seen
|
||||
ON drone_detections(last_seen, confidence)
|
||||
''')
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_drone_tracks_detection_time
|
||||
ON drone_tracks(detection_id, timestamp)
|
||||
''')
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_drone_incidents_status
|
||||
ON drone_incidents(status, opened_at)
|
||||
''')
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_action_requests_status
|
||||
ON action_requests(status, requested_at)
|
||||
''')
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_action_approvals_request
|
||||
ON action_approvals(request_id, approved_at)
|
||||
''')
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_evidence_manifests_incident
|
||||
ON evidence_manifests(incident_id, created_at)
|
||||
''')
|
||||
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
||||
@@ -2469,806 +2303,3 @@ def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
|
||||
return True, 'Removed'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Drone Ops / Professional Ops Functions
|
||||
# =============================================================================
|
||||
|
||||
def _decode_json(raw: str | None, default: Any = None) -> Any:
|
||||
"""Decode JSON safely with fallback default."""
|
||||
if raw is None or raw == '':
|
||||
return default
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return default
|
||||
|
||||
|
||||
def create_drone_session(
|
||||
mode: str = 'passive',
|
||||
label: str | None = None,
|
||||
operator: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> int:
|
||||
"""Create a Drone Ops session and return its ID."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO drone_sessions (mode, label, operator, metadata)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (mode, label, operator, json.dumps(metadata) if metadata else None))
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def stop_drone_session(session_id: int, summary: dict | None = None) -> bool:
|
||||
"""Stop an active Drone Ops session."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
UPDATE drone_sessions
|
||||
SET stopped_at = CURRENT_TIMESTAMP, summary = ?
|
||||
WHERE id = ? AND stopped_at IS NULL
|
||||
''', (json.dumps(summary) if summary else None, session_id))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_drone_session(session_id: int) -> dict | None:
|
||||
"""Get Drone Ops session by ID."""
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM drone_sessions WHERE id = ?',
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row['id'],
|
||||
'mode': row['mode'],
|
||||
'label': row['label'],
|
||||
'operator': row['operator'],
|
||||
'metadata': _decode_json(row['metadata'], {}),
|
||||
'started_at': row['started_at'],
|
||||
'stopped_at': row['stopped_at'],
|
||||
'summary': _decode_json(row['summary'], {}),
|
||||
'active': row['stopped_at'] is None,
|
||||
}
|
||||
|
||||
|
||||
def get_active_drone_session() -> dict | None:
|
||||
"""Return currently active Drone Ops session, if any."""
|
||||
with get_db() as conn:
|
||||
row = conn.execute('''
|
||||
SELECT * FROM drone_sessions
|
||||
WHERE stopped_at IS NULL
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
''').fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row['id'],
|
||||
'mode': row['mode'],
|
||||
'label': row['label'],
|
||||
'operator': row['operator'],
|
||||
'metadata': _decode_json(row['metadata'], {}),
|
||||
'started_at': row['started_at'],
|
||||
'stopped_at': row['stopped_at'],
|
||||
'summary': _decode_json(row['summary'], {}),
|
||||
'active': True,
|
||||
}
|
||||
|
||||
|
||||
def list_drone_sessions(limit: int = 50, active_only: bool = False) -> list[dict]:
|
||||
"""List Drone Ops sessions."""
|
||||
query = '''
|
||||
SELECT * FROM drone_sessions
|
||||
'''
|
||||
params: list[Any] = []
|
||||
if active_only:
|
||||
query += ' WHERE stopped_at IS NULL'
|
||||
query += ' ORDER BY started_at DESC LIMIT ?'
|
||||
params.append(limit)
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [
|
||||
{
|
||||
'id': row['id'],
|
||||
'mode': row['mode'],
|
||||
'label': row['label'],
|
||||
'operator': row['operator'],
|
||||
'metadata': _decode_json(row['metadata'], {}),
|
||||
'started_at': row['started_at'],
|
||||
'stopped_at': row['stopped_at'],
|
||||
'summary': _decode_json(row['summary'], {}),
|
||||
'active': row['stopped_at'] is None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def upsert_drone_detection(
|
||||
session_id: int | None,
|
||||
source: str,
|
||||
identifier: str,
|
||||
classification: str | None = None,
|
||||
confidence: float = 0.0,
|
||||
payload: dict | None = None,
|
||||
remote_id: dict | None = None,
|
||||
) -> int:
|
||||
"""Insert or update a Drone Ops detection, returning detection ID."""
|
||||
source = (source or '').strip().lower()
|
||||
identifier = (identifier or '').strip()
|
||||
if not source or not identifier:
|
||||
raise ValueError('source and identifier are required')
|
||||
|
||||
with get_db() as conn:
|
||||
if session_id is None:
|
||||
row = conn.execute('''
|
||||
SELECT id FROM drone_detections
|
||||
WHERE session_id IS NULL AND source = ? AND identifier = ?
|
||||
''', (source, identifier)).fetchone()
|
||||
else:
|
||||
row = conn.execute('''
|
||||
SELECT id FROM drone_detections
|
||||
WHERE session_id = ? AND source = ? AND identifier = ?
|
||||
''', (session_id, source, identifier)).fetchone()
|
||||
|
||||
payload_json = json.dumps(payload) if payload is not None else None
|
||||
remote_id_json = json.dumps(remote_id) if remote_id is not None else None
|
||||
|
||||
if row:
|
||||
detection_id = int(row['id'])
|
||||
conn.execute('''
|
||||
UPDATE drone_detections
|
||||
SET
|
||||
classification = COALESCE(?, classification),
|
||||
confidence = CASE WHEN ? > confidence THEN ? ELSE confidence END,
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
payload_json = COALESCE(?, payload_json),
|
||||
remote_id_json = COALESCE(?, remote_id_json)
|
||||
WHERE id = ?
|
||||
''', (
|
||||
classification,
|
||||
confidence,
|
||||
confidence,
|
||||
payload_json,
|
||||
remote_id_json,
|
||||
detection_id,
|
||||
))
|
||||
return detection_id
|
||||
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO drone_detections
|
||||
(session_id, source, identifier, classification, confidence, payload_json, remote_id_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
session_id,
|
||||
source,
|
||||
identifier,
|
||||
classification,
|
||||
confidence,
|
||||
payload_json,
|
||||
remote_id_json,
|
||||
))
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def get_drone_detection(detection_id: int) -> dict | None:
|
||||
"""Get a Drone Ops detection by ID."""
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM drone_detections WHERE id = ?',
|
||||
(detection_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row['id'],
|
||||
'session_id': row['session_id'],
|
||||
'source': row['source'],
|
||||
'identifier': row['identifier'],
|
||||
'classification': row['classification'],
|
||||
'confidence': float(row['confidence'] or 0.0),
|
||||
'first_seen': row['first_seen'],
|
||||
'last_seen': row['last_seen'],
|
||||
'payload': _decode_json(row['payload_json'], {}),
|
||||
'remote_id': _decode_json(row['remote_id_json'], {}),
|
||||
}
|
||||
|
||||
|
||||
def list_drone_detections(
|
||||
session_id: int | None = None,
|
||||
source: str | None = None,
|
||||
min_confidence: float = 0.0,
|
||||
limit: int = 200,
|
||||
) -> list[dict]:
|
||||
"""List Drone Ops detections with optional filters."""
|
||||
conditions = ['confidence >= ?']
|
||||
params: list[Any] = [min_confidence]
|
||||
|
||||
if session_id is not None:
|
||||
conditions.append('session_id = ?')
|
||||
params.append(session_id)
|
||||
if source:
|
||||
conditions.append('source = ?')
|
||||
params.append(source.strip().lower())
|
||||
|
||||
where_clause = 'WHERE ' + ' AND '.join(conditions)
|
||||
params.append(limit)
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(f'''
|
||||
SELECT * FROM drone_detections
|
||||
{where_clause}
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT ?
|
||||
''', params).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': row['id'],
|
||||
'session_id': row['session_id'],
|
||||
'source': row['source'],
|
||||
'identifier': row['identifier'],
|
||||
'classification': row['classification'],
|
||||
'confidence': float(row['confidence'] or 0.0),
|
||||
'first_seen': row['first_seen'],
|
||||
'last_seen': row['last_seen'],
|
||||
'payload': _decode_json(row['payload_json'], {}),
|
||||
'remote_id': _decode_json(row['remote_id_json'], {}),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def add_drone_track(
|
||||
detection_id: int,
|
||||
lat: float | None = None,
|
||||
lon: float | None = None,
|
||||
altitude_m: float | None = None,
|
||||
speed_mps: float | None = None,
|
||||
heading_deg: float | None = None,
|
||||
quality: float | None = None,
|
||||
source: str | None = None,
|
||||
) -> int:
|
||||
"""Add a track point for a detection."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO drone_tracks
|
||||
(detection_id, lat, lon, altitude_m, speed_mps, heading_deg, quality, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (detection_id, lat, lon, altitude_m, speed_mps, heading_deg, quality, source))
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def list_drone_tracks(
|
||||
detection_id: int | None = None,
|
||||
identifier: str | None = None,
|
||||
limit: int = 1000,
|
||||
) -> list[dict]:
|
||||
"""List track points by detection ID or detection identifier."""
|
||||
params: list[Any] = []
|
||||
where_clause = ''
|
||||
join_clause = ''
|
||||
|
||||
if detection_id is not None:
|
||||
where_clause = 'WHERE t.detection_id = ?'
|
||||
params.append(detection_id)
|
||||
elif identifier:
|
||||
join_clause = 'JOIN drone_detections d ON t.detection_id = d.id'
|
||||
where_clause = 'WHERE d.identifier = ?'
|
||||
params.append(identifier)
|
||||
|
||||
params.append(limit)
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(f'''
|
||||
SELECT t.*
|
||||
FROM drone_tracks t
|
||||
{join_clause}
|
||||
{where_clause}
|
||||
ORDER BY t.timestamp DESC
|
||||
LIMIT ?
|
||||
''', params).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': row['id'],
|
||||
'detection_id': row['detection_id'],
|
||||
'timestamp': row['timestamp'],
|
||||
'lat': row['lat'],
|
||||
'lon': row['lon'],
|
||||
'altitude_m': row['altitude_m'],
|
||||
'speed_mps': row['speed_mps'],
|
||||
'heading_deg': row['heading_deg'],
|
||||
'quality': row['quality'],
|
||||
'source': row['source'],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def add_drone_correlation(
|
||||
drone_identifier: str,
|
||||
operator_identifier: str,
|
||||
method: str,
|
||||
confidence: float,
|
||||
evidence: dict | None = None,
|
||||
) -> int:
|
||||
"""Store a drone/operator correlation result."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO drone_correlations
|
||||
(drone_identifier, operator_identifier, method, confidence, evidence_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (
|
||||
drone_identifier,
|
||||
operator_identifier,
|
||||
method,
|
||||
confidence,
|
||||
json.dumps(evidence) if evidence else None,
|
||||
))
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def list_drone_correlations(
|
||||
drone_identifier: str | None = None,
|
||||
min_confidence: float = 0.0,
|
||||
limit: int = 200,
|
||||
) -> list[dict]:
|
||||
"""List drone correlation records."""
|
||||
conditions = ['confidence >= ?']
|
||||
params: list[Any] = [min_confidence]
|
||||
if drone_identifier:
|
||||
conditions.append('drone_identifier = ?')
|
||||
params.append(drone_identifier)
|
||||
params.append(limit)
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(f'''
|
||||
SELECT * FROM drone_correlations
|
||||
WHERE {" AND ".join(conditions)}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
''', params).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': row['id'],
|
||||
'drone_identifier': row['drone_identifier'],
|
||||
'operator_identifier': row['operator_identifier'],
|
||||
'method': row['method'],
|
||||
'confidence': float(row['confidence'] or 0.0),
|
||||
'evidence': _decode_json(row['evidence_json'], {}),
|
||||
'created_at': row['created_at'],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def create_drone_incident(
|
||||
title: str,
|
||||
severity: str = 'medium',
|
||||
opened_by: str | None = None,
|
||||
summary: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> int:
|
||||
"""Create a Drone Ops incident."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO drone_incidents
|
||||
(title, severity, opened_by, summary, metadata)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (title, severity, opened_by, summary, json.dumps(metadata) if metadata else None))
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def update_drone_incident(
|
||||
incident_id: int,
|
||||
status: str | None = None,
|
||||
severity: str | None = None,
|
||||
summary: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> bool:
|
||||
"""Update incident status/metadata."""
|
||||
updates = []
|
||||
params: list[Any] = []
|
||||
|
||||
if status is not None:
|
||||
updates.append('status = ?')
|
||||
params.append(status)
|
||||
if status.lower() == 'closed':
|
||||
updates.append('closed_at = CURRENT_TIMESTAMP')
|
||||
elif status.lower() in {'open', 'active'}:
|
||||
updates.append('closed_at = NULL')
|
||||
if severity is not None:
|
||||
updates.append('severity = ?')
|
||||
params.append(severity)
|
||||
if summary is not None:
|
||||
updates.append('summary = ?')
|
||||
params.append(summary)
|
||||
if metadata is not None:
|
||||
updates.append('metadata = ?')
|
||||
params.append(json.dumps(metadata))
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
params.append(incident_id)
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
f'UPDATE drone_incidents SET {", ".join(updates)} WHERE id = ?',
|
||||
params,
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def add_drone_incident_artifact(
|
||||
incident_id: int,
|
||||
artifact_type: str,
|
||||
artifact_ref: str,
|
||||
added_by: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> int:
|
||||
"""Add an artifact reference to an incident."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO drone_incident_artifacts
|
||||
(incident_id, artifact_type, artifact_ref, added_by, metadata)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (
|
||||
incident_id,
|
||||
artifact_type,
|
||||
artifact_ref,
|
||||
added_by,
|
||||
json.dumps(metadata) if metadata else None,
|
||||
))
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def get_drone_incident(incident_id: int) -> dict | None:
|
||||
"""Get an incident with linked artifacts and manifests."""
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM drone_incidents WHERE id = ?',
|
||||
(incident_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
artifacts_rows = conn.execute('''
|
||||
SELECT * FROM drone_incident_artifacts
|
||||
WHERE incident_id = ?
|
||||
ORDER BY added_at DESC
|
||||
''', (incident_id,)).fetchall()
|
||||
|
||||
manifests_rows = conn.execute('''
|
||||
SELECT id, incident_id, created_at, hash_algo, signature, created_by, manifest_json
|
||||
FROM evidence_manifests
|
||||
WHERE incident_id = ?
|
||||
ORDER BY created_at DESC
|
||||
''', (incident_id,)).fetchall()
|
||||
|
||||
return {
|
||||
'id': row['id'],
|
||||
'title': row['title'],
|
||||
'status': row['status'],
|
||||
'severity': row['severity'],
|
||||
'opened_by': row['opened_by'],
|
||||
'opened_at': row['opened_at'],
|
||||
'closed_at': row['closed_at'],
|
||||
'summary': row['summary'],
|
||||
'metadata': _decode_json(row['metadata'], {}),
|
||||
'artifacts': [
|
||||
{
|
||||
'id': a['id'],
|
||||
'incident_id': a['incident_id'],
|
||||
'artifact_type': a['artifact_type'],
|
||||
'artifact_ref': a['artifact_ref'],
|
||||
'added_by': a['added_by'],
|
||||
'added_at': a['added_at'],
|
||||
'metadata': _decode_json(a['metadata'], {}),
|
||||
}
|
||||
for a in artifacts_rows
|
||||
],
|
||||
'manifests': [
|
||||
{
|
||||
'id': m['id'],
|
||||
'incident_id': m['incident_id'],
|
||||
'created_at': m['created_at'],
|
||||
'hash_algo': m['hash_algo'],
|
||||
'signature': m['signature'],
|
||||
'created_by': m['created_by'],
|
||||
'manifest': _decode_json(m['manifest_json'], {}),
|
||||
}
|
||||
for m in manifests_rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def list_drone_incidents(status: str | None = None, limit: int = 100) -> list[dict]:
|
||||
"""List incidents."""
|
||||
query = 'SELECT * FROM drone_incidents'
|
||||
params: list[Any] = []
|
||||
if status:
|
||||
query += ' WHERE status = ?'
|
||||
params.append(status)
|
||||
query += ' ORDER BY opened_at DESC LIMIT ?'
|
||||
params.append(limit)
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [
|
||||
{
|
||||
'id': row['id'],
|
||||
'title': row['title'],
|
||||
'status': row['status'],
|
||||
'severity': row['severity'],
|
||||
'opened_by': row['opened_by'],
|
||||
'opened_at': row['opened_at'],
|
||||
'closed_at': row['closed_at'],
|
||||
'summary': row['summary'],
|
||||
'metadata': _decode_json(row['metadata'], {}),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def create_action_request(
|
||||
incident_id: int,
|
||||
action_type: str,
|
||||
requested_by: str,
|
||||
payload: dict | None = None,
|
||||
) -> int:
|
||||
"""Create an action request record."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO action_requests
|
||||
(incident_id, action_type, requested_by, payload_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (
|
||||
incident_id,
|
||||
action_type,
|
||||
requested_by,
|
||||
json.dumps(payload) if payload else None,
|
||||
))
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def update_action_request_status(
|
||||
request_id: int,
|
||||
status: str,
|
||||
executed_by: str | None = None,
|
||||
) -> bool:
|
||||
"""Update action request status."""
|
||||
with get_db() as conn:
|
||||
if status.lower() == 'executed':
|
||||
cursor = conn.execute('''
|
||||
UPDATE action_requests
|
||||
SET status = ?, executed_at = CURRENT_TIMESTAMP, executed_by = ?
|
||||
WHERE id = ?
|
||||
''', (status, executed_by, request_id))
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
'UPDATE action_requests SET status = ? WHERE id = ?',
|
||||
(status, request_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_action_request(request_id: int) -> dict | None:
|
||||
"""Get action request with approval summary."""
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM action_requests WHERE id = ?',
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
approvals = conn.execute('''
|
||||
SELECT id, request_id, approved_by, approved_at, decision, notes
|
||||
FROM action_approvals
|
||||
WHERE request_id = ?
|
||||
ORDER BY approved_at ASC
|
||||
''', (request_id,)).fetchall()
|
||||
|
||||
return {
|
||||
'id': row['id'],
|
||||
'incident_id': row['incident_id'],
|
||||
'action_type': row['action_type'],
|
||||
'requested_by': row['requested_by'],
|
||||
'requested_at': row['requested_at'],
|
||||
'status': row['status'],
|
||||
'payload': _decode_json(row['payload_json'], {}),
|
||||
'executed_at': row['executed_at'],
|
||||
'executed_by': row['executed_by'],
|
||||
'approvals': [
|
||||
{
|
||||
'id': ap['id'],
|
||||
'request_id': ap['request_id'],
|
||||
'approved_by': ap['approved_by'],
|
||||
'approved_at': ap['approved_at'],
|
||||
'decision': ap['decision'],
|
||||
'notes': ap['notes'],
|
||||
}
|
||||
for ap in approvals
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def list_action_requests(
|
||||
incident_id: int | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict]:
|
||||
"""List action requests with optional filtering."""
|
||||
conditions = []
|
||||
params: list[Any] = []
|
||||
if incident_id is not None:
|
||||
conditions.append('incident_id = ?')
|
||||
params.append(incident_id)
|
||||
if status:
|
||||
conditions.append('status = ?')
|
||||
params.append(status)
|
||||
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
|
||||
params.append(limit)
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(f'''
|
||||
SELECT * FROM action_requests
|
||||
{where_clause}
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT ?
|
||||
''', params).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': row['id'],
|
||||
'incident_id': row['incident_id'],
|
||||
'action_type': row['action_type'],
|
||||
'requested_by': row['requested_by'],
|
||||
'requested_at': row['requested_at'],
|
||||
'status': row['status'],
|
||||
'payload': _decode_json(row['payload_json'], {}),
|
||||
'executed_at': row['executed_at'],
|
||||
'executed_by': row['executed_by'],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def add_action_approval(
|
||||
request_id: int,
|
||||
approved_by: str,
|
||||
decision: str = 'approved',
|
||||
notes: str | None = None,
|
||||
) -> int:
|
||||
"""Add an approval decision to an action request."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO action_approvals
|
||||
(request_id, approved_by, decision, notes)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (request_id, approved_by, decision, notes))
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def add_action_audit_log(
|
||||
request_id: int | None,
|
||||
event_type: str,
|
||||
actor: str | None = None,
|
||||
details: dict | None = None,
|
||||
) -> int:
|
||||
"""Append an action audit log event."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO action_audit_log
|
||||
(request_id, event_type, actor, details_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (
|
||||
request_id,
|
||||
event_type,
|
||||
actor,
|
||||
json.dumps(details) if details else None,
|
||||
))
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def list_action_audit_logs(
|
||||
request_id: int | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[dict]:
|
||||
"""List action audit logs."""
|
||||
params: list[Any] = []
|
||||
where_clause = ''
|
||||
if request_id is not None:
|
||||
where_clause = 'WHERE request_id = ?'
|
||||
params.append(request_id)
|
||||
params.append(limit)
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(f'''
|
||||
SELECT * FROM action_audit_log
|
||||
{where_clause}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
''', params).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': row['id'],
|
||||
'request_id': row['request_id'],
|
||||
'event_type': row['event_type'],
|
||||
'actor': row['actor'],
|
||||
'timestamp': row['timestamp'],
|
||||
'details': _decode_json(row['details_json'], {}),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def create_evidence_manifest(
|
||||
incident_id: int,
|
||||
manifest: dict,
|
||||
hash_algo: str = 'sha256',
|
||||
signature: str | None = None,
|
||||
created_by: str | None = None,
|
||||
) -> int:
|
||||
"""Store evidence manifest metadata for an incident."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO evidence_manifests
|
||||
(incident_id, hash_algo, manifest_json, signature, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (
|
||||
incident_id,
|
||||
hash_algo,
|
||||
json.dumps(manifest),
|
||||
signature,
|
||||
created_by,
|
||||
))
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def get_evidence_manifest(manifest_id: int) -> dict | None:
|
||||
"""Get an evidence manifest by ID."""
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM evidence_manifests WHERE id = ?',
|
||||
(manifest_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'id': row['id'],
|
||||
'incident_id': row['incident_id'],
|
||||
'created_at': row['created_at'],
|
||||
'hash_algo': row['hash_algo'],
|
||||
'manifest': _decode_json(row['manifest_json'], {}),
|
||||
'signature': row['signature'],
|
||||
'created_by': row['created_by'],
|
||||
}
|
||||
|
||||
|
||||
def list_evidence_manifests(incident_id: int, limit: int = 50) -> list[dict]:
|
||||
"""List manifests for an incident."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute('''
|
||||
SELECT * FROM evidence_manifests
|
||||
WHERE incident_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
''', (incident_id, limit)).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': row['id'],
|
||||
'incident_id': row['incident_id'],
|
||||
'created_at': row['created_at'],
|
||||
'hash_algo': row['hash_algo'],
|
||||
'manifest': _decode_json(row['manifest_json'], {}),
|
||||
'signature': row['signature'],
|
||||
'created_by': row['created_by'],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""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',
|
||||
]
|
||||
@@ -1,305 +0,0 @@
|
||||
"""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 []
|
||||
@@ -1,11 +0,0 @@
|
||||
"""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
|
||||
@@ -1,121 +0,0 @@
|
||||
"""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
|
||||
@@ -1,621 +0,0 @@
|
||||
"""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,14 +54,6 @@ 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:
|
||||
value = event.get(field)
|
||||
|
||||
Reference in New Issue
Block a user