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