Remove Drone Ops feature end-to-end

This commit is contained in:
Smittix
2026-02-20 17:09:17 +00:00
parent 9cf74f93ca
commit 690165cafa
19 changed files with 2 additions and 5231 deletions
-74
View File
@@ -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
-969
View File
@@ -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
]
-10
View File
@@ -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',
]
-305
View File
@@ -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 []
-11
View File
@@ -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
-121
View File
@@ -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
-621
View File
@@ -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
-8
View File
@@ -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)