diff --git a/docs/PROFESSIONAL_OPS_MVP.md b/docs/PROFESSIONAL_OPS_MVP.md deleted file mode 100644 index f687805..0000000 --- a/docs/PROFESSIONAL_OPS_MVP.md +++ /dev/null @@ -1,192 +0,0 @@ -# Professional Ops + Drone Capability Matrix (MVP) - -This plan enables full professional capability (passive, active testing, and evidence workflows) while keeping strict authorization, approvals, and auditability. - -## 1) Capability Matrix (Feature Availability) - -| Capability | Passive (Observe) | Active (Controlled Test) | Evidence / Audit | Reuse in Current Codebase | MVP Build Additions | -|---|---|---|---|---|---| -| Drone detection and classification | Detect likely drone entities from WiFi/BLE/RF metadata | Trigger controlled test sessions to validate detector quality | Store detections with source, confidence, and timestamps | `/wifi/v2/*`, `/api/bluetooth/*`, `/subghz/*`, `utils/tscm/detector.py` | New detector adapters in `utils/drone/` and aggregation API | -| Remote ID intelligence | Parse and display drone/operator identifiers and telemetry | Run controlled replay/simulation inputs for validation | Persist decoded records and parser provenance | `routes/wifi_v2.py`, `routes/bluetooth_v2.py`, `utils/event_pipeline.py` | `utils/drone/remote_id.py`, `/drone-ops/remote-id/*` endpoints | -| C2 and video link analysis | Identify likely C2/video channels and protocol patterns | Controlled injection/exercise mode for authorized ranges | Save link assessments and confidence history | `routes/listening_post.py`, `routes/subghz.py`, `static/js/components/signal-guess.js` | `utils/drone/link_analysis.py`, `/drone-ops/links/*` | -| Multi-agent geolocation | Estimate emitter/drone position from distributed observations | Active test mode to validate location solver error bounds | Capture estimate history with confidence ellipse | `routes/controller.py` location endpoints | `/drone-ops/geolocate/*` wrapper over controller location APIs | -| Operator/drone correlation | Correlate drone, operator, and nearby device candidates | Active proximity probes in controlled tests | Store correlation graph and confidence deltas | `/correlation`, `/analytics/target`, TSCM identity clustering | `utils/drone/correlation.py`, `/drone-ops/correlations/*` | -| Geofence and rules | Alert on zone breach and route/altitude anomalies | Zone-based active scenario testing | Immutable breach timeline and alert acknowledgements | `utils/geofence.py`, `/analytics/geofences`, `/alerts/*` | Drone-specific alert templates and rule presets | -| Incident command workflow | Build incident timeline from detections/alerts/tracks | Execute approved active tasks per playbook | Case package with linked artifacts and operator notes | TSCM cases and notes in `routes/tscm.py`, `utils/database.py` | Drone case types + incident board UI in Drone Ops mode | -| Replay and reporting | In-app replay of full incident event stream | Replay active test sessions for after-action review | Export signed package (JSONL + summary + hashes) | `/recordings/*`, Analytics replay UI, TSCM report generation | Evidence manifest + integrity hashing + chain-of-custody log | -| Active action controls | N/A | Full active actions available when armed/approved | Every action requires explicit reason and audit record | Existing active surfaces in `/wifi/deauth`, `/subghz/transmit` | Approval workflow (`two-person`) + command gate middleware | -| Access control and approvals | Role-based read access | Role + arming + approval enforced per action class | Full action audit trail with actor, approver, and case ID | `users.role` and session role in `app.py` | `utils/authz.py`, approval/audit tables, route decorators | - -## 2) Architecture Mapping to Existing Routes/UI - -## Backend routes to reuse directly - -- Detection feeds: - - `routes/wifi_v2.py` (`/wifi/v2/stream`, `/wifi/v2/networks`, `/wifi/v2/clients`, `/wifi/v2/probes`) - - `routes/bluetooth_v2.py` (`/api/bluetooth/stream`, `/api/bluetooth/devices`) - - `routes/subghz.py` (`/subghz/stream`, receive/decode status) -- Correlation and analytics: - - `routes/correlation.py` - - `routes/analytics.py` (`/analytics/target`, `/analytics/patterns`, `/analytics/geofences`) -- Multi-agent geolocation: - - `routes/controller.py` (`/controller/api/location/estimate`, `/controller/api/location/observe`, `/controller/api/location/all`) -- Alerts and recording: - - `routes/alerts.py` and `utils/alerts.py` - - `routes/recordings.py` and `utils/recording.py` - - Shared pipeline in `utils/event_pipeline.py` -- Case and reporting substrate: - - `routes/tscm.py` (`/tscm/cases`, `/tscm/report/pdf`, playbooks) - - TSCM persistence in `utils/database.py` - -## New backend module for MVP - -- Add `routes/drone_ops.py` with URL prefix `/drone-ops`. -- Add `utils/drone/` package: - - `aggregator.py` (normalize events from WiFi/BLE/RF) - - `remote_id.py` (parsers + confidence attribution) - - `link_analysis.py` (C2/video heuristics) - - `geo.py` (adapter to controller location estimation) - - `policy.py` (arming, approval, role checks) - -## Frontend integration points - -- Navigation: - - `templates/partials/nav.html` add `droneops` entry (Intel group). -- Mode panel: - - `templates/partials/modes/droneops.html` - - `static/js/modes/droneops.js` - - `static/css/modes/droneops.css` -- Main mode loader wiring: - - `templates/index.html` for new panel include + script/css registration. -- Cross-mode widgets: - - Reuse `static/js/components/signal-cards.js`, `timeline-heatmap.js`, `activity-timeline.js`. - -## 3) Proposed MVP API Surface (`/drone-ops`) - -| Endpoint | Method | Purpose | -|---|---|---| -| `/drone-ops/status` | `GET` | Health, active session, arming state, policy snapshot | -| `/drone-ops/session/start` | `POST` | Start passive/active Drone Ops session with metadata | -| `/drone-ops/session/stop` | `POST` | Stop session and finalize summary | -| `/drone-ops/detections` | `GET` | Current detection list with filters | -| `/drone-ops/stream` | `GET` (SSE) | Unified live stream (detections, tracks, alerts, approvals) | -| `/drone-ops/remote-id/decode` | `POST` | Decode submitted frame payload (test and replay support) | -| `/drone-ops/tracks` | `GET` | Track list and selected track history | -| `/drone-ops/geolocate/estimate` | `POST` | Request geolocation estimate from distributed observations | -| `/drone-ops/correlations` | `GET` | Drone/operator/device correlation graph | -| `/drone-ops/incidents` | `POST` / `GET` | Create/list incidents | -| `/drone-ops/incidents/` | `GET` / `PUT` | Incident detail and status updates | -| `/drone-ops/incidents//artifacts` | `POST` | Attach notes, detections, tracks, alerts, recordings | -| `/drone-ops/actions/arm` | `POST` | Arm active action plane with reason + case/incident ID | -| `/drone-ops/actions/request` | `POST` | Submit active action requiring approval policy | -| `/drone-ops/actions/approve/` | `POST` | Secondary approval (if required) | -| `/drone-ops/actions/execute/` | `POST` | Execute approved action via gated dispatcher | - -## 4) Data Model Additions (SQLite, MVP) - -Add to `utils/database.py`: - -- `drone_sessions` - - `id`, `started_at`, `stopped_at`, `mode` (`passive`/`active`), `operator`, `metadata` -- `drone_detections` - - `id`, `session_id`, `first_seen`, `last_seen`, `source`, `identifier`, `confidence`, `payload_json` -- `drone_tracks` - - `id`, `detection_id`, `timestamp`, `lat`, `lon`, `altitude_m`, `speed_mps`, `heading_deg`, `quality` -- `drone_correlations` - - `id`, `drone_identifier`, `operator_identifier`, `method`, `confidence`, `evidence_json`, `created_at` -- `drone_incidents` - - `id`, `title`, `status`, `severity`, `opened_by`, `opened_at`, `closed_at`, `summary` -- `drone_incident_artifacts` - - `id`, `incident_id`, `artifact_type`, `artifact_ref`, `added_by`, `added_at`, `metadata` -- `action_requests` - - `id`, `incident_id`, `action_type`, `requested_by`, `requested_at`, `status`, `payload_json` -- `action_approvals` - - `id`, `request_id`, `approved_by`, `approved_at`, `decision`, `notes` -- `action_audit_log` - - `id`, `request_id`, `event_type`, `actor`, `timestamp`, `details_json` -- `evidence_manifests` - - `id`, `incident_id`, `created_at`, `hash_algo`, `manifest_json`, `signature` - -Note: existing `recording_sessions` and `alert_events` remain the primary event substrate; drone tables link to those records for case assembly. - -## 5) Authorization and Arming Model (All Features Available) - -All features remain implemented and reachable in code. Execution path depends on policy state. - -- Roles (extend `users.role` semantics): - - `viewer`: read-only - - `analyst`: passive + evidence operations - - `operator`: passive + active request submission - - `supervisor`: approval authority - - `admin`: full control + policy management -- Active command state machine: - - `DISARMED` (default): active commands denied - - `ARMED` (time-bound): request creation allowed with incident ID and reason - - `APPROVED`: dual-approval actions executable - - `EXECUTED`: immutable audit records written -- Enforcement: - - Add decorators in `utils/authz.py`: - - `@require_role(...)` - - `@require_armed` - - `@require_two_person_approval` - -## 6) MVP Delivery Plan (6 Weeks) - -## Phase 0 (Week 1): Scaffolding - -- Add `routes/drone_ops.py` blueprint and register in `routes/__init__.py`. -- Add `utils/drone/` package with aggregator skeleton. -- Add Drone Ops UI placeholders (`droneops.html`, `droneops.js`, `droneops.css`) and nav wiring. -- Add DB migration/create statements for drone tables. - -Exit criteria: -- Drone Ops mode loads, API health endpoint returns, empty-state UI renders. - -## Phase 1 (Weeks 1-2): Passive Drone Ops - -- Unified ingest from WiFi/BLE/SubGHz streams. -- Detection cards, live timeline, map tracks, geofence alert hooks. -- Remote ID decode endpoint and parser confidence model. -- Alert rule presets for drone intrusions. - -Exit criteria: -- Passive session can detect/classify, map updates in real time, alerts generated. - -## Phase 2 (Weeks 3-4): Correlation + Geolocation + Incident Workflow - -- Correlation graph (drone/operator/nearby device candidates). -- Multi-agent geolocation adapter using controller location endpoints. -- Incident creation and artifact linking. -- Replay integration using existing recordings/events APIs. - -Exit criteria: -- Operator can open incident, attach artifacts, replay key timeline, export preliminary report. - -## Phase 3 (Weeks 5-6): Active Actions + Evidence Integrity - -- Arming and approval workflows (`action_requests`, `action_approvals`). -- Active action dispatcher with role/policy checks. -- Evidence manifest export with hashes and chain-of-custody entries. -- Audit dashboards for who requested/approved/executed. - -Exit criteria: -- Active commands require approvals, all operations are auditable and exportable. - -## 7) Immediate Build Backlog (First Sprint) - -1. Create `routes/drone_ops.py` with `status`, `session/start`, `session/stop`, `stream`. -2. Add drone tables in `utils/database.py` and lightweight DAO helpers. -3. Add mode shell UI files and wire mode into `templates/index.html` and `templates/partials/nav.html`. -4. Implement aggregator wiring to existing WiFi/BT/SubGHz feeds via `utils/event_pipeline.py`. -5. Add `actions/arm` endpoint with role + incident requirement and TTL-based disarm. -6. Add baseline tests: - - `tests/test_drone_ops_routes.py` - - `tests/test_drone_ops_policy.py` - - `tests/test_drone_ops_remote_id.py` - -## 8) Risk Controls - -- False attribution risk: every correlation/geolocation output carries confidence and evidence provenance. -- Policy bypass risk: active command execution path only through centralized dispatcher. -- Evidence integrity risk: hash all exported artifacts and include manifest references. -- Operational safety risk: require explicit incident linkage and arming expiration. diff --git a/routes/__init__.py b/routes/__init__.py index e294894..844a117 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -36,7 +36,6 @@ def register_blueprints(app): from .bt_locate import bt_locate_bp from .analytics import analytics_bp from .space_weather import space_weather_bp - from .drone_ops import drone_ops_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -72,7 +71,6 @@ def register_blueprints(app): app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard app.register_blueprint(space_weather_bp) # Space weather monitoring - app.register_blueprint(drone_ops_bp) # Drone Ops / professional workflow # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/drone_ops.py b/routes/drone_ops.py deleted file mode 100644 index 23de0d3..0000000 --- a/routes/drone_ops.py +++ /dev/null @@ -1,431 +0,0 @@ -"""Drone Ops routes: professional workflow for detection, incidents, actions, and evidence.""" - -from __future__ import annotations - -from flask import Blueprint, Response, jsonify, request - -from utils.authz import current_username, require_armed, require_role -from utils.database import ( - get_action_request, - get_drone_incident, - get_evidence_manifest, - list_action_audit_logs, - list_action_requests, - list_drone_correlations, - list_drone_sessions, - list_drone_incidents, - list_evidence_manifests, -) -from utils.drone import get_drone_ops_service -from utils.sse import format_sse - - -drone_ops_bp = Blueprint('drone_ops', __name__, url_prefix='/drone-ops') - - -def _json_body() -> dict: - return request.get_json(silent=True) or {} - - -@drone_ops_bp.route('/status', methods=['GET']) -@require_role('viewer') -def status() -> Response: - service = get_drone_ops_service() - return jsonify(service.get_status()) - - -@drone_ops_bp.route('/sessions', methods=['GET']) -@require_role('viewer') -def list_sessions() -> Response: - limit = max(1, min(500, request.args.get('limit', default=50, type=int))) - active_only = request.args.get('active_only', 'false').lower() == 'true' - return jsonify({ - 'status': 'success', - 'sessions': list_drone_sessions(limit=limit, active_only=active_only), - }) - - -@drone_ops_bp.route('/session/start', methods=['POST']) -@require_role('operator') -def start_session() -> Response: - data = _json_body() - mode = str(data.get('mode') or 'passive').strip().lower() - if mode not in {'passive', 'active'}: - return jsonify({'status': 'error', 'message': 'mode must be passive or active'}), 400 - - label = data.get('label') - metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} - - service = get_drone_ops_service() - session = service.start_session( - mode=mode, - label=label, - operator=current_username(), - metadata=metadata, - ) - return jsonify({'status': 'success', 'session': session}) - - -@drone_ops_bp.route('/session/stop', methods=['POST']) -@require_role('operator') -def stop_session() -> Response: - data = _json_body() - session_id = data.get('id') - try: - session_id_int = int(session_id) if session_id is not None else None - except (TypeError, ValueError): - return jsonify({'status': 'error', 'message': 'id must be an integer'}), 400 - - summary = data.get('summary') if isinstance(data.get('summary'), dict) else None - service = get_drone_ops_service() - session = service.stop_session( - operator=current_username(), - session_id=session_id_int, - summary=summary, - ) - if not session: - return jsonify({'status': 'error', 'message': 'No active session found'}), 404 - return jsonify({'status': 'success', 'session': session}) - - -@drone_ops_bp.route('/detections', methods=['GET']) -@require_role('viewer') -def detections() -> Response: - service = get_drone_ops_service() - source = request.args.get('source') - min_confidence = request.args.get('min_confidence', default=0.0, type=float) - limit = max(1, min(5000, request.args.get('limit', default=200, type=int))) - session_id = request.args.get('session_id', default=None, type=int) - - rows = service.get_detections( - session_id=session_id, - source=source, - min_confidence=min_confidence, - limit=limit, - ) - return jsonify({'status': 'success', 'count': len(rows), 'detections': rows}) - - -@drone_ops_bp.route('/stream', methods=['GET']) -@require_role('viewer') -def stream() -> Response: - service = get_drone_ops_service() - - def _generate(): - for event in service.stream_events(timeout=1.0): - evt_name = event.get('type') if isinstance(event, dict) else None - payload = event - yield format_sse(payload, event=evt_name) - - response = Response(_generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['Connection'] = 'keep-alive' - response.headers['X-Accel-Buffering'] = 'no' - return response - - -@drone_ops_bp.route('/remote-id/decode', methods=['POST']) -@require_role('analyst') -def decode_remote_id() -> Response: - data = _json_body() - payload = data.get('payload') - if payload is None: - return jsonify({'status': 'error', 'message': 'payload is required'}), 400 - - service = get_drone_ops_service() - decoded = service.decode_remote_id(payload) - return jsonify({'status': 'success', 'decoded': decoded}) - - -@drone_ops_bp.route('/tracks', methods=['GET']) -@require_role('viewer') -def tracks() -> Response: - service = get_drone_ops_service() - detection_id = request.args.get('detection_id', default=None, type=int) - identifier = request.args.get('identifier') - limit = max(1, min(5000, request.args.get('limit', default=1000, type=int))) - - rows = service.get_tracks(detection_id=detection_id, identifier=identifier, limit=limit) - return jsonify({'status': 'success', 'count': len(rows), 'tracks': rows}) - - -@drone_ops_bp.route('/geolocate/estimate', methods=['POST']) -@require_role('analyst') -def geolocate_estimate() -> Response: - data = _json_body() - observations = data.get('observations') - environment = str(data.get('environment') or 'outdoor') - - if not isinstance(observations, list) or len(observations) < 3: - return jsonify({'status': 'error', 'message': 'at least 3 observations are required'}), 400 - - service = get_drone_ops_service() - try: - location = service.estimate_geolocation(observations=observations, environment=environment) - except Exception as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 - - return jsonify({'status': 'success', 'location': location}) - - -@drone_ops_bp.route('/correlations', methods=['GET']) -@require_role('analyst') -def correlations() -> Response: - min_confidence = request.args.get('min_confidence', default=0.6, type=float) - refresh = request.args.get('refresh', 'true').lower() == 'true' - service = get_drone_ops_service() - - if refresh: - rows = service.refresh_correlations(min_confidence=min_confidence) - else: - rows = list_drone_correlations(min_confidence=min_confidence, limit=200) - - return jsonify({'status': 'success', 'count': len(rows), 'correlations': rows}) - - -@drone_ops_bp.route('/incidents', methods=['GET']) -@require_role('viewer') -def incidents_list() -> Response: - status = request.args.get('status') - limit = max(1, min(1000, request.args.get('limit', default=100, type=int))) - rows = list_drone_incidents(status=status, limit=limit) - return jsonify({'status': 'success', 'count': len(rows), 'incidents': rows}) - - -@drone_ops_bp.route('/incidents', methods=['POST']) -@require_role('operator') -def incidents_create() -> Response: - data = _json_body() - title = str(data.get('title') or '').strip() - if not title: - return jsonify({'status': 'error', 'message': 'title is required'}), 400 - - severity = str(data.get('severity') or 'medium').strip().lower() - summary = data.get('summary') - metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} - - service = get_drone_ops_service() - incident = service.create_incident( - title=title, - severity=severity, - opened_by=current_username(), - summary=summary, - metadata=metadata, - ) - return jsonify({'status': 'success', 'incident': incident}), 201 - - -@drone_ops_bp.route('/incidents/', methods=['GET']) -@require_role('viewer') -def incidents_get(incident_id: int) -> Response: - incident = get_drone_incident(incident_id) - if not incident: - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - return jsonify({'status': 'success', 'incident': incident}) - - -@drone_ops_bp.route('/incidents/', methods=['PUT']) -@require_role('operator') -def incidents_update(incident_id: int) -> Response: - data = _json_body() - service = get_drone_ops_service() - incident = service.update_incident( - incident_id=incident_id, - status=data.get('status'), - severity=data.get('severity'), - summary=data.get('summary'), - metadata=data.get('metadata') if isinstance(data.get('metadata'), dict) else None, - ) - if not incident: - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - return jsonify({'status': 'success', 'incident': incident}) - - -@drone_ops_bp.route('/incidents//artifacts', methods=['POST']) -@require_role('operator') -def incidents_add_artifact(incident_id: int) -> Response: - if not get_drone_incident(incident_id): - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - - data = _json_body() - artifact_type = str(data.get('artifact_type') or '').strip() - artifact_ref = str(data.get('artifact_ref') or '').strip() - metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} - - if not artifact_type or not artifact_ref: - return jsonify({'status': 'error', 'message': 'artifact_type and artifact_ref are required'}), 400 - - service = get_drone_ops_service() - artifact = service.add_incident_artifact( - incident_id=incident_id, - artifact_type=artifact_type, - artifact_ref=artifact_ref, - added_by=current_username(), - metadata=metadata, - ) - return jsonify({'status': 'success', 'artifact': artifact}), 201 - - -@drone_ops_bp.route('/actions/arm', methods=['POST']) -@require_role('operator') -def actions_arm() -> Response: - data = _json_body() - reason = str(data.get('reason') or '').strip() - incident_id = data.get('incident_id') - duration_seconds = data.get('duration_seconds', 900) - - if not reason: - return jsonify({'status': 'error', 'message': 'reason is required'}), 400 - try: - incident_id_int = int(incident_id) - except (TypeError, ValueError): - return jsonify({'status': 'error', 'message': 'incident_id is required and must be an integer'}), 400 - - if not get_drone_incident(incident_id_int): - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - - service = get_drone_ops_service() - state = service.arm_actions( - actor=current_username(), - reason=reason, - incident_id=incident_id_int, - duration_seconds=duration_seconds, - ) - return jsonify({'status': 'success', 'policy': state}) - - -@drone_ops_bp.route('/actions/disarm', methods=['POST']) -@require_role('operator') -def actions_disarm() -> Response: - data = _json_body() - reason = str(data.get('reason') or '').strip() or None - service = get_drone_ops_service() - state = service.disarm_actions(actor=current_username(), reason=reason) - return jsonify({'status': 'success', 'policy': state}) - - -@drone_ops_bp.route('/actions/request', methods=['POST']) -@require_role('operator') -def actions_request() -> Response: - data = _json_body() - try: - incident_id = int(data.get('incident_id')) - except (TypeError, ValueError): - return jsonify({'status': 'error', 'message': 'incident_id is required'}), 400 - - if not get_drone_incident(incident_id): - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - - action_type = str(data.get('action_type') or '').strip() - if not action_type: - return jsonify({'status': 'error', 'message': 'action_type is required'}), 400 - - payload = data.get('payload') if isinstance(data.get('payload'), dict) else {} - - service = get_drone_ops_service() - action_request = service.request_action( - incident_id=incident_id, - action_type=action_type, - requested_by=current_username(), - payload=payload, - ) - return jsonify({'status': 'success', 'request': action_request}), 201 - - -@drone_ops_bp.route('/actions/approve/', methods=['POST']) -@require_role('supervisor') -def actions_approve(request_id: int) -> Response: - data = _json_body() - decision = str(data.get('decision') or 'approved').strip().lower() - notes = data.get('notes') - - if decision not in {'approved', 'rejected'}: - return jsonify({'status': 'error', 'message': 'decision must be approved or rejected'}), 400 - - service = get_drone_ops_service() - req = service.approve_action( - request_id=request_id, - approver=current_username(), - decision=decision, - notes=notes, - ) - if not req: - return jsonify({'status': 'error', 'message': 'Action request not found'}), 404 - return jsonify({'status': 'success', 'request': req}) - - -@drone_ops_bp.route('/actions/execute/', methods=['POST']) -@require_role('operator') -@require_armed -def actions_execute(request_id: int) -> Response: - service = get_drone_ops_service() - req, error = service.execute_action(request_id=request_id, actor=current_username()) - if error: - return jsonify({'status': 'error', 'message': error}), 400 - return jsonify({'status': 'success', 'request': req}) - - -@drone_ops_bp.route('/actions/requests', methods=['GET']) -@require_role('viewer') -def actions_list() -> Response: - incident_id = request.args.get('incident_id', default=None, type=int) - status = request.args.get('status') - limit = max(1, min(1000, request.args.get('limit', default=100, type=int))) - - rows = list_action_requests(incident_id=incident_id, status=status, limit=limit) - return jsonify({'status': 'success', 'count': len(rows), 'requests': rows}) - - -@drone_ops_bp.route('/actions/requests/', methods=['GET']) -@require_role('viewer') -def actions_get(request_id: int) -> Response: - row = get_action_request(request_id) - if not row: - return jsonify({'status': 'error', 'message': 'Action request not found'}), 404 - return jsonify({'status': 'success', 'request': row}) - - -@drone_ops_bp.route('/actions/audit', methods=['GET']) -@require_role('viewer') -def actions_audit() -> Response: - request_id = request.args.get('request_id', default=None, type=int) - limit = max(1, min(2000, request.args.get('limit', default=200, type=int))) - rows = list_action_audit_logs(request_id=request_id, limit=limit) - return jsonify({'status': 'success', 'count': len(rows), 'events': rows}) - - -@drone_ops_bp.route('/evidence//manifest', methods=['POST']) -@require_role('analyst') -def evidence_manifest_create(incident_id: int) -> Response: - if not get_drone_incident(incident_id): - return jsonify({'status': 'error', 'message': 'Incident not found'}), 404 - - data = _json_body() - signature = data.get('signature') - - service = get_drone_ops_service() - manifest = service.generate_evidence_manifest( - incident_id=incident_id, - created_by=current_username(), - signature=signature, - ) - if not manifest: - return jsonify({'status': 'error', 'message': 'Failed to generate manifest'}), 500 - return jsonify({'status': 'success', 'manifest': manifest}), 201 - - -@drone_ops_bp.route('/evidence/manifests/', methods=['GET']) -@require_role('viewer') -def evidence_manifest_get(manifest_id: int) -> Response: - row = get_evidence_manifest(manifest_id) - if not row: - return jsonify({'status': 'error', 'message': 'Manifest not found'}), 404 - return jsonify({'status': 'success', 'manifest': row}) - - -@drone_ops_bp.route('/evidence//manifests', methods=['GET']) -@require_role('viewer') -def evidence_manifest_list(incident_id: int) -> Response: - limit = max(1, min(500, request.args.get('limit', default=50, type=int))) - rows = list_evidence_manifests(incident_id=incident_id, limit=limit) - return jsonify({'status': 'success', 'count': len(rows), 'manifests': rows}) diff --git a/static/css/modes/droneops.css b/static/css/modes/droneops.css deleted file mode 100644 index 573e360..0000000 --- a/static/css/modes/droneops.css +++ /dev/null @@ -1,498 +0,0 @@ -/* Drone Ops mode styling */ - -#droneOpsMode { - display: none; -} - -#droneOpsMode.active { - display: block; -} - -.droneops-status-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-bottom: 10px; -} - -.droneops-status-card { - border: 1px solid var(--border-color); - background: var(--bg-secondary); - border-radius: 6px; - padding: 8px; -} - -.droneops-status-label { - font-size: 10px; - color: var(--text-secondary); - margin-bottom: 4px; - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.droneops-status-value { - font-size: 13px; - font-weight: 700; - color: var(--text-primary); -} - -.droneops-row { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-bottom: 8px; - align-items: stretch; -} - -.droneops-row > * { - min-width: 0; -} - -.droneops-row--actions { - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); -} - -.droneops-source-block { - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--bg-secondary); - padding: 8px; - margin-bottom: 8px; -} - -.droneops-source-title { - font-size: 10px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.08em; - margin-bottom: 6px; -} - -.droneops-row--sources { - margin-bottom: 0; -} - -.droneops-field { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.droneops-field-label { - font-size: 10px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.droneops-source-refresh { - grid-column: 1 / -1; -} - -.droneops-input { - width: 100%; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - color: var(--text-primary); - border-radius: 5px; - padding: 6px 8px; - font-size: 11px; -} - -#droneOpsMode .preset-btn, -#droneOpsMode .clear-btn { - width: 100%; - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: center; - text-align: center; - line-height: 1.2; - white-space: normal !important; - overflow-wrap: anywhere; - word-break: break-word; - letter-spacing: 0.05em; - font-size: 11px; - padding: 8px 10px; -} - -.droneops-list { - max-height: 260px; - overflow-y: auto; - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--bg-secondary); -} - -.droneops-item { - border-bottom: 1px solid var(--border-color); - padding: 8px; - font-size: 11px; -} - -.droneops-item:last-child { - border-bottom: none; -} - -.droneops-item-title { - font-weight: 700; - color: var(--text-primary); -} - -.droneops-item-meta { - margin-top: 3px; - color: var(--text-secondary); - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -.droneops-item-actions { - margin-top: 6px; - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.droneops-check { - width: 100%; - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: flex-start; - gap: 6px; - font-size: 11px; - color: var(--text-primary); - border: 1px solid var(--border-color); - background: var(--bg-tertiary); - border-radius: 5px; - padding: 6px 8px; -} - -body[data-mode="droneops"] #muteBtn, -body[data-mode="droneops"] #autoScrollBtn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -body[data-mode="droneops"] #muteBtn .icon, -body[data-mode="droneops"] #autoScrollBtn .icon { - line-height: 0; -} - -.droneops-check input { - margin: 0; -} - -.droneops-subtext { - margin: 4px 0 10px; - font-size: 11px; - color: var(--text-secondary); -} - -.droneops-pill { - display: inline-flex; - align-items: center; - border-radius: 999px; - padding: 2px 8px; - font-size: 10px; - border: 1px solid var(--border-color); - color: var(--text-secondary); -} - -.droneops-pill.ok { - color: var(--accent-green); - border-color: var(--accent-green); -} - -.droneops-pill.warn { - color: var(--accent-orange); - border-color: var(--accent-orange); -} - -.droneops-pill.bad { - color: var(--accent-red); - border-color: var(--accent-red); -} - -.droneops-empty { - color: var(--text-secondary); - padding: 10px; - font-size: 11px; -} - -.droneops-main-pane { - display: none; - flex: 1; - min-height: 0; - padding: 10px; - gap: 10px; - flex-direction: column; -} - -.droneops-main-top { - display: grid; - grid-template-columns: minmax(0, 1.8fr) minmax(300px, 1fr); - gap: 10px; - min-height: 0; - flex: 1; -} - -.droneops-main-bottom { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - min-height: 260px; - flex: 1; -} - -.droneops-main-side { - display: grid; - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); - gap: 10px; - min-height: 0; -} - -.droneops-main-map-card, -.droneops-main-card { - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; -} - -.droneops-main-card-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 10px 12px; - border-bottom: 1px solid var(--border-color); - background: var(--bg-tertiary); -} - -.droneops-main-card-header h4 { - margin: 0; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-primary); -} - -.droneops-main-card-meta { - font-size: 10px; - color: var(--text-secondary); - font-family: var(--font-mono); -} - -.droneops-main-kpis { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - padding: 10px; -} - -.droneops-main-kpi { - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--bg-primary); - padding: 8px; - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.droneops-main-kpi span { - font-size: 10px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.droneops-main-kpi strong { - font-size: 15px; - color: var(--accent-cyan); - line-height: 1; - overflow-wrap: anywhere; -} - -.droneops-main-map { - flex: 1; - min-height: 320px; -} - -#droneOpsMap { - width: 100%; - height: 100%; - min-height: 320px; - background: var(--bg-primary); -} - -.droneops-main-list { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 8px; - display: flex; - flex-direction: column; - gap: 8px; -} - -.droneops-main-list.compact { - gap: 6px; -} - -.droneops-main-item { - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 8px; - background: var(--bg-primary); -} - -.droneops-main-item-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.droneops-main-item-id { - color: var(--text-primary); - font-weight: 700; - font-size: 12px; - overflow-wrap: anywhere; -} - -.droneops-main-item-meta { - margin-top: 4px; - color: var(--text-secondary); - display: flex; - flex-wrap: wrap; - gap: 8px; - font-size: 11px; -} - -.droneops-main-telemetry-row { - display: grid; - grid-template-columns: minmax(0, 1.2fr) repeat(5, minmax(0, 1fr)); - gap: 8px; - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 6px 8px; - background: var(--bg-primary); - font-family: var(--font-mono); - font-size: 10px; - color: var(--text-secondary); -} - -.droneops-main-telemetry-row strong { - color: var(--text-primary); - font-weight: 700; -} - -.droneops-main-telemetry-row > span { - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.droneops-main-correlation-row { - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 7px 8px; - background: var(--bg-primary); - display: flex; - flex-direction: column; - gap: 3px; -} - -.droneops-main-correlation-row strong { - font-size: 11px; - color: var(--text-primary); - overflow-wrap: anywhere; -} - -.droneops-main-correlation-row span { - font-size: 10px; - color: var(--text-secondary); -} - -.droneops-main-pane .leaflet-popup-content-wrapper { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); -} - -.droneops-main-pane .leaflet-popup-tip { - background: var(--bg-secondary); -} - -@media (max-width: 1400px) { - .droneops-main-top { - grid-template-columns: 1fr; - } - - .droneops-main-side { - grid-template-columns: repeat(2, minmax(0, 1fr)); - grid-template-rows: none; - } -} - -@media (max-width: 1100px) { - .droneops-main-bottom { - grid-template-columns: 1fr; - } - - .droneops-main-kpis { - grid-template-columns: 1fr; - } -} - -@media (max-width: 780px) { - .droneops-main-side { - grid-template-columns: 1fr; - } - - .droneops-main-telemetry-row { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .droneops-main-telemetry-row > span:first-child { - grid-column: 1 / -1; - } - - .droneops-main-map, - #droneOpsMap { - min-height: 240px; - } -} - -@media (max-width: 900px) { - .droneops-row { - grid-template-columns: repeat(2, minmax(120px, 1fr)); - } - - .droneops-row--actions { - grid-template-columns: repeat(2, minmax(120px, 1fr)); - } -} - -@media (max-width: 560px) { - .droneops-row { - grid-template-columns: 1fr; - } - - .droneops-row--actions { - grid-template-columns: 1fr; - } -} diff --git a/static/js/modes/droneops.js b/static/js/modes/droneops.js deleted file mode 100644 index cfc939c..0000000 --- a/static/js/modes/droneops.js +++ /dev/null @@ -1,1418 +0,0 @@ -/** - * Drone Ops mode frontend. - */ -const DroneOps = (function () { - 'use strict'; - - let initialized = false; - let refreshTimer = null; - let stream = null; - let latestDetections = []; - let latestTracks = []; - let latestCorrelations = []; - let correlationAccess = 'unknown'; - let correlationRefreshCount = 0; - let map = null; - let mapMarkers = null; - let mapTracks = null; - let mapHeat = null; - let mapNeedsAutoFit = true; - let lastCorrelationError = ''; - const DETECTION_START_WAIT_MS = 1500; - const SOURCE_COLORS = { - wifi: '#00d4ff', - bluetooth: '#00ff88', - rf: '#ff9f43', - remote_id: '#f04dff', - }; - - function esc(value) { - return String(value === undefined || value === null ? '' : value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function notify(message, isError = false) { - if (typeof SignalCards !== 'undefined' && SignalCards.showToast) { - SignalCards.showToast(message, isError ? 'error' : 'success'); - return; - } - if (typeof showNotification === 'function') { - showNotification(isError ? 'Drone Ops Error' : 'Drone Ops', message); - return; - } - if (isError) { - console.error(message); - } else { - console.log(message); - } - } - - async function api(path, options = {}) { - const response = await fetch(path, { - headers: { 'Content-Type': 'application/json' }, - ...options, - }); - const data = await response.json().catch(() => ({})); - if (!response.ok || data.status === 'error') { - const error = new Error(data.message || `Request failed (${response.status})`); - error.status = response.status; - throw error; - } - return data; - } - - async function fetchJson(path, options = {}) { - const response = await fetch(path, options); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.message || data.error || `Request failed (${response.status})`); - } - return data; - } - - async function apiOptional(path, options = {}) { - try { - return await api(path, options); - } catch (error) { - return { __error: error }; - } - } - - function confidenceClass(conf) { - if (conf >= 0.8) return 'ok'; - if (conf >= 0.6) return 'warn'; - return 'bad'; - } - - function toNumber(value) { - const n = Number(value); - return Number.isFinite(n) ? n : null; - } - - function hasCoords(lat, lon) { - return lat !== null && lon !== null && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; - } - - function formatCoord(value) { - const num = toNumber(value); - return num === null ? '--' : num.toFixed(5); - } - - function formatMetric(value, decimals = 1, suffix = '') { - const num = toNumber(value); - if (num === null) return '--'; - return `${num.toFixed(decimals)}${suffix}`; - } - - function sourceColor(source) { - return SOURCE_COLORS[String(source || '').toLowerCase()] || '#7f8ea3'; - } - - function defaultMapCenter() { - if (typeof ObserverLocation !== 'undefined' && typeof ObserverLocation.getForModule === 'function') { - const location = ObserverLocation.getForModule('droneops_observerLocation', { fallbackToLatLon: true }); - const lat = toNumber(location?.lat); - const lon = toNumber(location?.lon); - if (hasCoords(lat, lon)) return [lat, lon]; - } - const fallbackLat = toNumber(window.INTERCEPT_DEFAULT_LAT); - const fallbackLon = toNumber(window.INTERCEPT_DEFAULT_LON); - if (hasCoords(fallbackLat, fallbackLon)) return [fallbackLat, fallbackLon]; - return [37.0902, -95.7129]; - } - - function sortedTracksByDetection() { - const grouped = new Map(); - for (const raw of latestTracks) { - const detectionId = Number(raw?.detection_id); - if (!detectionId) continue; - if (!grouped.has(detectionId)) grouped.set(detectionId, []); - grouped.get(detectionId).push(raw); - } - for (const rows of grouped.values()) { - rows.sort((a, b) => String(a?.timestamp || '').localeCompare(String(b?.timestamp || ''))); - } - return grouped; - } - - function detectionTelemetry(detection, tracksByDetection) { - const rows = tracksByDetection.get(Number(detection?.id)) || []; - const latestTrack = rows.length ? rows[rows.length - 1] : null; - const remote = detection && typeof detection.remote_id === 'object' ? detection.remote_id : {}; - const lat = toNumber(latestTrack?.lat ?? remote.lat); - const lon = toNumber(latestTrack?.lon ?? remote.lon); - return { - lat, - lon, - hasPosition: hasCoords(lat, lon), - altitude_m: toNumber(latestTrack?.altitude_m ?? remote.altitude_m), - speed_mps: toNumber(latestTrack?.speed_mps ?? remote.speed_mps), - heading_deg: toNumber(latestTrack?.heading_deg ?? remote.heading_deg), - quality: toNumber(latestTrack?.quality ?? remote.confidence ?? detection?.confidence), - source: latestTrack?.source || detection?.source, - timestamp: latestTrack?.timestamp || detection?.last_seen || '', - uas_id: remote?.uas_id || null, - operator_id: remote?.operator_id || null, - trackRows: rows, - }; - } - - function connectStream() { - if (stream || !initialized) return; - stream = new EventSource('/drone-ops/stream'); - - const handler = (event) => { - let payload = null; - try { - payload = JSON.parse(event.data); - } catch (_) { - return; - } - if (!payload || payload.type === 'keepalive') return; - - if (payload.type === 'detection') { - refreshDetections(); - refreshTracks(); - refreshCorrelations(); - refreshStatus(); - return; - } - - if (payload.type.startsWith('incident_')) { - refreshIncidents(); - refreshStatus(); - return; - } - - if (payload.type.startsWith('action_') || payload.type.startsWith('policy_')) { - refreshActions(); - refreshStatus(); - return; - } - - if (payload.type.startsWith('evidence_')) { - refreshManifests(); - return; - } - }; - - stream.onmessage = handler; - stream.onerror = () => { - if (stream) { - stream.close(); - stream = null; - } - setTimeout(() => { - if (initialized) connectStream(); - }, 2000); - }; - } - - function disconnectStream() { - if (stream) { - stream.close(); - stream = null; - } - } - - function setText(id, value) { - const el = document.getElementById(id); - if (el) el.textContent = value; - } - - function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - function isAgentMode() { - return typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - } - - function wifiRunning() { - if (typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) { - return true; - } - if (typeof isWifiRunning !== 'undefined' && isWifiRunning) { - return true; - } - return false; - } - - function bluetoothRunning() { - if (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.isScanning === 'function' && BluetoothMode.isScanning()) { - return true; - } - if (typeof isBtRunning !== 'undefined' && isBtRunning) { - return true; - } - return false; - } - - function updateSensorsState() { - const active = []; - if (wifiRunning()) active.push('WiFi'); - if (bluetoothRunning()) active.push('Bluetooth'); - setText('droneOpsSensorsState', active.length ? `Running (${active.join(' + ')})` : 'Idle'); - } - - function applySelectOptions(selectId, rows, autoLabel) { - const select = document.getElementById(selectId); - if (!select) return; - const previous = String(select.value || ''); - const seen = new Set(); - const options = [``]; - - for (const row of rows) { - const value = String(row?.value || '').trim(); - if (!value || seen.has(value)) continue; - seen.add(value); - const label = String(row?.label || value); - options.push(``); - } - - select.innerHTML = options.join(''); - if (previous && seen.has(previous)) { - select.value = previous; - } - } - - function readExistingSelectOptions(selectId) { - const select = document.getElementById(selectId); - if (!select) return []; - return Array.from(select.options || []) - .map((opt) => ({ - value: String(opt?.value || '').trim(), - label: String(opt?.textContent || opt?.label || '').trim(), - })) - .filter((opt) => opt.value); - } - - function selectHasChoices(selectId) { - return readExistingSelectOptions(selectId).length > 0; - } - - function ensureSelectValue(select, value, label = '') { - if (!select || !value) return; - const target = String(value); - const existing = Array.from(select.options || []).find((opt) => String(opt.value) === target); - if (!existing) { - const option = document.createElement('option'); - option.value = target; - option.textContent = label || target; - select.appendChild(option); - } - select.value = target; - } - - async function fetchWifiSourceOptions() { - if (isAgentMode()) { - const agentId = typeof currentAgent !== 'undefined' ? currentAgent : null; - if (!agentId || agentId === 'local') return []; - const data = await fetchJson(`/controller/agents/${agentId}?refresh=true`); - const rows = data?.agent?.interfaces?.wifi_interfaces || []; - return rows.map((item) => { - if (typeof item === 'string') { - return { value: item, label: item }; - } - const value = String(item?.name || item?.id || '').trim(); - if (!value) return null; - let label = String(item?.display_name || value); - if (!item?.display_name && item?.type) label += ` (${item.type})`; - if (item?.monitor_capable || item?.supports_monitor) label += ' [Monitor OK]'; - return { value, label }; - }).filter(Boolean); - } - - let rows = []; - try { - const data = await fetchJson('/wifi/interfaces'); - rows = data?.interfaces || []; - } catch (_) { - rows = []; - } - - const mapped = rows.map((item) => { - const value = String(item?.name || item?.id || '').trim(); - if (!value) return null; - let label = value; - const details = []; - if (item?.chipset) details.push(item.chipset); - else if (item?.driver) details.push(item.driver); - if (details.length) label += ` - ${details.join(' | ')}`; - if (item?.type) label += ` (${item.type})`; - if (item?.monitor_capable || item?.supports_monitor) label += ' [Monitor OK]'; - return { value, label }; - }).filter(Boolean); - - if (mapped.length) return mapped; - - if (typeof refreshWifiInterfaces === 'function') { - try { - await Promise.resolve(refreshWifiInterfaces()); - await sleep(250); - } catch (_) { - // Fall back to currently populated options. - } - } - - return readExistingSelectOptions('wifiInterfaceSelect'); - } - - async function fetchBluetoothSourceOptions() { - if (isAgentMode()) { - const agentId = typeof currentAgent !== 'undefined' ? currentAgent : null; - if (!agentId || agentId === 'local') return []; - const data = await fetchJson(`/controller/agents/${agentId}?refresh=true`); - const rows = data?.agent?.interfaces?.bt_adapters || []; - return rows.map((item) => { - if (typeof item === 'string') { - return { value: item, label: item }; - } - const value = String(item?.id || item?.name || '').trim(); - if (!value) return null; - let label = item?.name && item.name !== value ? `${value} - ${item.name}` : value; - if (item?.powered === false) label += ' [DOWN]'; - else if (item?.powered === true) label += ' [UP]'; - return { value, label }; - }).filter(Boolean); - } - - let rows = []; - try { - const data = await fetchJson('/api/bluetooth/capabilities'); - rows = data?.adapters || []; - } catch (_) { - rows = []; - } - - const mapped = rows.map((item) => { - const value = String(item?.id || item?.name || '').trim(); - if (!value) return null; - let label = item?.name && item.name !== value ? `${value} - ${item.name}` : value; - if (item?.powered === false) label += ' [DOWN]'; - else if (item?.powered === true) label += ' [UP]'; - return { value, label }; - }).filter(Boolean); - - if (mapped.length) return mapped; - - if (typeof refreshBtInterfaces === 'function') { - try { - await Promise.resolve(refreshBtInterfaces()); - await sleep(250); - } catch (_) { - // Fall back to currently populated options. - } - } - - return readExistingSelectOptions('btAdapterSelect'); - } - - function applySelectedSourceToModeSelectors() { - const wifiChosen = String(document.getElementById('droneOpsWifiInterfaceSelect')?.value || '').trim(); - if (wifiChosen) { - const wifiSelect = document.getElementById('wifiInterfaceSelect'); - ensureSelectValue(wifiSelect, wifiChosen, wifiChosen); - // Force fresh monitor-interface derivation for the selected adapter. - if (typeof monitorInterface !== 'undefined' && monitorInterface && monitorInterface !== wifiChosen) { - monitorInterface = null; - } - } - - const btChosen = String(document.getElementById('droneOpsBtAdapterSelect')?.value || '').trim(); - if (btChosen) { - const btSelect = document.getElementById('btAdapterSelect'); - ensureSelectValue(btSelect, btChosen, btChosen); - } - } - - async function refreshDetectionSources(silent = false) { - const wifiSelect = document.getElementById('droneOpsWifiInterfaceSelect'); - const btSelect = document.getElementById('droneOpsBtAdapterSelect'); - if (wifiSelect) wifiSelect.innerHTML = ''; - if (btSelect) btSelect.innerHTML = ''; - - const [wifiResult, btResult] = await Promise.allSettled([ - fetchWifiSourceOptions(), - fetchBluetoothSourceOptions(), - ]); - - if (wifiResult.status === 'fulfilled') { - applySelectOptions('droneOpsWifiInterfaceSelect', wifiResult.value, 'Auto WiFi source'); - } else { - applySelectOptions('droneOpsWifiInterfaceSelect', [], 'Auto WiFi source'); - if (!silent) notify(`WiFi source refresh failed: ${wifiResult.reason?.message || 'unknown error'}`, true); - } - - if (btResult.status === 'fulfilled') { - applySelectOptions('droneOpsBtAdapterSelect', btResult.value, 'Auto Bluetooth source'); - } else { - applySelectOptions('droneOpsBtAdapterSelect', [], 'Auto Bluetooth source'); - if (!silent) notify(`Bluetooth source refresh failed: ${btResult.reason?.message || 'unknown error'}`, true); - } - - applySelectedSourceToModeSelectors(); - if (!silent && wifiResult.status === 'fulfilled' && btResult.status === 'fulfilled') { - notify('Detection sources refreshed'); - } - } - - function addFallbackMapLayer(targetMap) { - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors © CARTO', - subdomains: 'abcd', - maxZoom: 19, - className: 'tile-layer-cyan', - }).addTo(targetMap); - } - - async function ensureMap() { - if (map || typeof L === 'undefined') return; - const mapEl = document.getElementById('droneOpsMap'); - if (!mapEl) return; - - map = L.map(mapEl, { - center: defaultMapCenter(), - zoom: 12, - minZoom: 2, - maxZoom: 19, - zoomControl: true, - }); - - if (typeof Settings !== 'undefined' && typeof Settings.createTileLayer === 'function') { - try { - await Settings.init(); - Settings.createTileLayer().addTo(map); - if (typeof Settings.registerMap === 'function') { - Settings.registerMap(map); - } - } catch (_) { - addFallbackMapLayer(map); - } - } else { - addFallbackMapLayer(map); - } - - mapTracks = L.layerGroup().addTo(map); - mapMarkers = L.layerGroup().addTo(map); - if (typeof L.heatLayer === 'function') { - mapHeat = L.heatLayer([], { - radius: 18, - blur: 16, - minOpacity: 0.28, - maxZoom: 18, - gradient: { - 0.2: '#00b7ff', - 0.45: '#00ff88', - 0.7: '#ffb400', - 1.0: '#ff355e', - }, - }).addTo(map); - } - setTimeout(() => { - if (map) map.invalidateSize(); - }, 120); - } - - function invalidateMap() { - mapNeedsAutoFit = true; - if (!map) { - ensureMap(); - return; - } - [80, 240, 500].forEach((delay) => { - setTimeout(() => { - if (map) map.invalidateSize(); - }, delay); - }); - } - - function renderMainSummary(detections, tracksByDetection, filteredTrackCount) { - const sources = new Set(); - let telemetryCount = 0; - - for (const d of detections) { - const source = String(d?.source || '').trim().toLowerCase(); - if (source) sources.add(source); - const telemetry = detectionTelemetry(d, tracksByDetection); - if (telemetry.hasPosition || telemetry.altitude_m !== null || telemetry.speed_mps !== null || telemetry.heading_deg !== null) { - telemetryCount += 1; - } - } - - setText('droneOpsMainSummaryDetections', String(detections.length)); - setText('droneOpsMainSummarySources', String(sources.size)); - setText('droneOpsMainSummaryTracks', String(filteredTrackCount)); - setText('droneOpsMainSummaryTelemetry', String(telemetryCount)); - if (correlationAccess === 'restricted') { - setText('droneOpsMainSummaryCorrelations', 'Role'); - } else { - setText('droneOpsMainSummaryCorrelations', String(latestCorrelations.length)); - } - setText('droneOpsMainLastUpdate', new Date().toLocaleTimeString()); - } - - function renderMainDetections(detections, tracksByDetection) { - const container = document.getElementById('droneOpsMainDetections'); - if (!container) return; - - if (!detections.length) { - container.innerHTML = '
No detections yet
'; - return; - } - - container.innerHTML = detections.slice(0, 150).map((d) => { - const conf = Number(d.confidence || 0); - const cls = confidenceClass(conf); - const telemetry = detectionTelemetry(d, tracksByDetection); - const ridSummary = []; - if (telemetry.uas_id) ridSummary.push(`uas: ${esc(telemetry.uas_id)}`); - if (telemetry.operator_id) ridSummary.push(`operator: ${esc(telemetry.operator_id)}`); - - return `
-
- ${esc(d.identifier)} - ${Math.round(conf * 100)}% -
-
- source: ${esc(d.source || 'unknown')} - class: ${esc(d.classification || 'unknown')} - last: ${esc(d.last_seen || '')} -
-
- lat: ${formatCoord(telemetry.lat)} - lon: ${formatCoord(telemetry.lon)} - alt: ${formatMetric(telemetry.altitude_m, 1, ' m')} - spd: ${formatMetric(telemetry.speed_mps, 1, ' m/s')} - hdg: ${formatMetric(telemetry.heading_deg, 0, '°')} -
- ${ridSummary.length ? `
${ridSummary.join(' • ')}
` : ''} -
`; - }).join(''); - } - - function renderMainTelemetry(detections, filteredTracks) { - const container = document.getElementById('droneOpsMainTelemetry'); - if (!container) return; - - const detectionById = new Map(detections.map((d) => [Number(d.id), d])); - - if (filteredTracks.length) { - const rows = filteredTracks - .slice() - .sort((a, b) => String(b?.timestamp || '').localeCompare(String(a?.timestamp || ''))) - .slice(0, 180); - - container.innerHTML = rows.map((t) => { - const detection = detectionById.get(Number(t.detection_id)); - const label = detection?.identifier || `#${Number(t.detection_id)}`; - const quality = toNumber(t.quality); - return `
- ${esc(label)} ${esc(t.timestamp || '')} - ${formatCoord(t.lat)}, ${formatCoord(t.lon)} - alt ${formatMetric(t.altitude_m, 1, 'm')} - spd ${formatMetric(t.speed_mps, 1, 'm/s')} - hdg ${formatMetric(t.heading_deg, 0, '°')} - ${esc(t.source || detection?.source || 'unknown')} • q ${quality === null ? '--' : quality.toFixed(2)} -
`; - }).join(''); - return; - } - - const tracksByDetection = sortedTracksByDetection(); - const telemetryRows = detections - .map((d) => ({ detection: d, telemetry: detectionTelemetry(d, tracksByDetection) })) - .filter((entry) => entry.telemetry.hasPosition || entry.telemetry.altitude_m !== null || entry.telemetry.speed_mps !== null || entry.telemetry.heading_deg !== null); - - if (!telemetryRows.length) { - container.innerHTML = '
No telemetry yet
'; - return; - } - - container.innerHTML = telemetryRows.slice(0, 120).map((entry) => { - const d = entry.detection; - const telemetry = entry.telemetry; - return `
- ${esc(d.identifier)} ${esc(telemetry.timestamp || '')} - ${formatCoord(telemetry.lat)}, ${formatCoord(telemetry.lon)} - alt ${formatMetric(telemetry.altitude_m, 1, 'm')} - spd ${formatMetric(telemetry.speed_mps, 1, 'm/s')} - hdg ${formatMetric(telemetry.heading_deg, 0, '°')} - ${esc(d.source || 'unknown')} -
`; - }).join(''); - } - - function renderMainCorrelations() { - const container = document.getElementById('droneOpsMainCorrelations'); - if (!container) return; - - if (correlationAccess === 'restricted') { - container.innerHTML = '
Correlation data requires analyst role
'; - return; - } - - if (correlationAccess === 'error') { - container.innerHTML = '
Correlation data unavailable
'; - return; - } - - if (!latestCorrelations.length) { - container.innerHTML = '
No correlations yet
'; - return; - } - - container.innerHTML = latestCorrelations.slice(0, 80).map((row) => { - const confidence = Number(row?.confidence || 0); - const cls = confidenceClass(confidence); - return `
- ${esc(row.drone_identifier || 'unknown')} → ${esc(row.operator_identifier || 'unknown')} - method: ${esc(row.method || 'n/a')} • ${esc(row.created_at || '')} - ${Math.round(confidence * 100)}% -
`; - }).join(''); - } - - function renderMapDetections(detections, tracksByDetection) { - if (!map) { - if (typeof L === 'undefined' || !document.getElementById('droneOpsMap')) return; - ensureMap().then(() => { - if (map) renderMapDetections(detections, tracksByDetection); - }).catch(() => {}); - return; - } - if (!mapMarkers || !mapTracks) return; - - mapMarkers.clearLayers(); - mapTracks.clearLayers(); - - const heatPoints = []; - const boundsPoints = []; - - for (const d of detections) { - const telemetry = detectionTelemetry(d, tracksByDetection); - const color = sourceColor(telemetry.source || d.source); - const pathPoints = []; - for (const row of telemetry.trackRows) { - const lat = toNumber(row?.lat); - const lon = toNumber(row?.lon); - if (!hasCoords(lat, lon)) continue; - const latLng = [lat, lon]; - pathPoints.push(latLng); - boundsPoints.push(latLng); - const intensity = Math.max(0.2, Math.min(1, toNumber(row?.quality ?? d.confidence) ?? 0.5)); - heatPoints.push([lat, lon, intensity]); - } - - if (pathPoints.length > 1) { - L.polyline(pathPoints, { - color, - weight: 2, - opacity: 0.75, - lineJoin: 'round', - }).addTo(mapTracks); - } - - if (telemetry.hasPosition) { - const latLng = [telemetry.lat, telemetry.lon]; - boundsPoints.push(latLng); - const intensity = Math.max(0.2, Math.min(1, telemetry.quality ?? toNumber(d.confidence) ?? 0.5)); - heatPoints.push([telemetry.lat, telemetry.lon, intensity]); - - L.circleMarker(latLng, { - radius: 6, - color, - fillColor: color, - fillOpacity: 0.88, - weight: 2, - }).bindPopup(` -
-
${esc(d.identifier)}
-
Source: ${esc(d.source || 'unknown')}
-
Confidence: ${Math.round(Number(d.confidence || 0) * 100)}%
-
Lat/Lon: ${formatCoord(telemetry.lat)}, ${formatCoord(telemetry.lon)}
-
Alt: ${formatMetric(telemetry.altitude_m, 1, ' m')} • Spd: ${formatMetric(telemetry.speed_mps, 1, ' m/s')}
-
Heading: ${formatMetric(telemetry.heading_deg, 0, '°')}
-
- `).addTo(mapMarkers); - } - } - - if (mapHeat && typeof mapHeat.setLatLngs === 'function') { - mapHeat.setLatLngs(heatPoints); - } - - if (boundsPoints.length && mapNeedsAutoFit) { - map.fitBounds(L.latLngBounds(boundsPoints), { padding: [24, 24], maxZoom: 16 }); - mapNeedsAutoFit = false; - } - - if (!boundsPoints.length) { - setText('droneOpsMapMeta', 'No geospatial telemetry yet'); - } else { - setText('droneOpsMapMeta', `${boundsPoints.length} geo points • ${heatPoints.length} heat samples`); - } - } - - function renderMainPane() { - const pane = document.getElementById('droneOpsMainPane'); - if (!pane) return; - - const detections = Array.isArray(latestDetections) ? latestDetections : []; - const detectionIds = new Set(detections.map((d) => Number(d.id)).filter(Boolean)); - const filteredTracks = (Array.isArray(latestTracks) ? latestTracks : []) - .filter((track) => detectionIds.has(Number(track?.detection_id))); - const tracksByDetection = sortedTracksByDetection(); - - renderMainSummary(detections, tracksByDetection, filteredTracks.length); - renderMainDetections(detections, tracksByDetection); - renderMainTelemetry(detections, filteredTracks); - renderMainCorrelations(); - renderMapDetections(detections, tracksByDetection); - } - - async function ensureSessionForDetection() { - try { - const status = await api('/drone-ops/status'); - if (!status.active_session) { - await api('/drone-ops/session/start', { - method: 'POST', - body: JSON.stringify({ mode: 'passive' }), - }); - } - } catch (_) { - // Detection can still run without an explicit Drone Ops session. - } - } - - async function startWifiDetection() { - if (isAgentMode()) { - if (typeof WiFiMode !== 'undefined') { - if (WiFiMode.init) WiFiMode.init(); - if (WiFiMode.startDeepScan) { - await WiFiMode.startDeepScan(); - await sleep(DETECTION_START_WAIT_MS); - if (wifiRunning()) return; - } - } - throw new Error('Unable to start WiFi detection in agent mode'); - } - - if (typeof WiFiMode !== 'undefined') { - if (WiFiMode.init) WiFiMode.init(); - if (WiFiMode.startDeepScan) { - await WiFiMode.startDeepScan(); - await sleep(DETECTION_START_WAIT_MS); - if (wifiRunning()) return; - } - } - - if (typeof startWifiScan === 'function') { - await Promise.resolve(startWifiScan()); - await sleep(DETECTION_START_WAIT_MS); - if (wifiRunning()) return; - } - - throw new Error('WiFi scan did not start'); - } - - async function startBluetoothDetection() { - if (typeof startBtScan === 'function') { - await Promise.resolve(startBtScan()); - await sleep(DETECTION_START_WAIT_MS); - if (bluetoothRunning()) return; - } - - if (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.startScan === 'function') { - await BluetoothMode.startScan(); - await sleep(DETECTION_START_WAIT_MS); - if (bluetoothRunning()) return; - } - - throw new Error('Bluetooth scan did not start'); - } - - async function stopWifiDetection() { - if (isAgentMode() && typeof WiFiMode !== 'undefined' && typeof WiFiMode.stopScan === 'function') { - await WiFiMode.stopScan(); - return; - } - - if (typeof stopWifiScan === 'function') { - await Promise.resolve(stopWifiScan()); - return; - } - - if (typeof WiFiMode !== 'undefined' && typeof WiFiMode.stopScan === 'function') { - await WiFiMode.stopScan(); - return; - } - } - - async function stopBluetoothDetection() { - if (typeof stopBtScan === 'function') { - await Promise.resolve(stopBtScan()); - return; - } - - if (typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.stopScan === 'function') { - await BluetoothMode.stopScan(); - return; - } - } - - async function refreshStatus() { - try { - const data = await api('/drone-ops/status'); - const active = data.active_session; - const policy = data.policy || {}; - const counts = data.counts || {}; - - setText('droneOpsSessionValue', active ? `${active.mode.toUpperCase()} #${active.id}` : 'Idle'); - setText('droneOpsArmedValue', policy.armed ? 'Yes' : 'No'); - setText('droneOpsDetectionCount', String(counts.detections || 0)); - setText('droneOpsIncidentCount', String(counts.incidents_open || 0)); - updateSensorsState(); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshDetections() { - const source = document.getElementById('droneOpsSourceFilter')?.value || ''; - const min = parseFloat(document.getElementById('droneOpsConfidenceFilter')?.value || '0.5'); - - try { - const data = await api(`/drone-ops/detections?limit=500&source=${encodeURIComponent(source)}&min_confidence=${encodeURIComponent(isNaN(min) ? 0.5 : min)}`); - const detections = data.detections || []; - latestDetections = detections; - const container = document.getElementById('droneOpsDetections'); - if (!container) { - renderMainPane(); - return; - } - - if (!detections.length) { - container.innerHTML = '
No detections yet
'; - renderMainPane(); - return; - } - - container.innerHTML = detections.map((d) => { - const conf = Number(d.confidence || 0); - const confPct = Math.round(conf * 100); - const cls = confidenceClass(conf); - return `
-
${esc(d.identifier)} ${confPct}%
-
- source: ${esc(d.source)} - class: ${esc(d.classification || 'unknown')} - last seen: ${esc(d.last_seen || '')} -
-
- -
-
`; - }).join(''); - renderMainPane(); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshTracks() { - try { - const data = await api('/drone-ops/tracks?limit=3000'); - latestTracks = Array.isArray(data.tracks) ? data.tracks : []; - renderMainPane(); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshCorrelations() { - const refreshFlag = correlationRefreshCount % 4 === 0; - correlationRefreshCount += 1; - const result = await apiOptional(`/drone-ops/correlations?min_confidence=0.5&limit=120&refresh=${refreshFlag ? 'true' : 'false'}`); - if (result.__error) { - if (result.__error?.status === 403) { - correlationAccess = 'restricted'; - latestCorrelations = []; - renderMainPane(); - return; - } - correlationAccess = 'error'; - latestCorrelations = []; - const message = String(result.__error?.message || 'Unable to load correlations'); - if (message !== lastCorrelationError) { - lastCorrelationError = message; - notify(message, true); - } - renderMainPane(); - return; - } - - correlationAccess = 'ok'; - lastCorrelationError = ''; - latestCorrelations = Array.isArray(result.correlations) ? result.correlations : []; - renderMainPane(); - } - - async function refreshIncidents() { - try { - const data = await api('/drone-ops/incidents?limit=100'); - const incidents = data.incidents || []; - const container = document.getElementById('droneOpsIncidents'); - if (!container) return; - - if (!incidents.length) { - container.innerHTML = '
No incidents
'; - return; - } - - container.innerHTML = incidents.map((i) => ` -
-
#${Number(i.id)} ${esc(i.title)} - ${esc(i.status)} -
-
- severity: ${esc(i.severity)} - opened: ${esc(i.opened_at || '')} -
-
- - -
-
- `).join(''); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshActions() { - try { - const data = await api('/drone-ops/actions/requests?limit=100'); - const rows = data.requests || []; - const container = document.getElementById('droneOpsActions'); - if (!container) return; - - if (!rows.length) { - container.innerHTML = '
No action requests
'; - return; - } - - container.innerHTML = rows.map((r) => { - const statusClass = r.status === 'executed' ? 'ok' : (r.status === 'approved' ? 'warn' : 'bad'); - return `
-
Request #${Number(r.id)} - ${esc(r.status)} -
-
- incident: ${Number(r.incident_id)} - action: ${esc(r.action_type)} - requested by: ${esc(r.requested_by)} -
-
- - -
-
`; - }).join(''); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshManifests() { - const incident = parseInt(document.getElementById('droneOpsManifestIncident')?.value || '0', 10); - const container = document.getElementById('droneOpsManifests'); - if (!container) return; - - if (!incident) { - container.innerHTML = '
Enter incident ID to list manifests
'; - return; - } - - try { - const data = await api(`/drone-ops/evidence/${incident}/manifests?limit=50`); - const rows = data.manifests || []; - if (!rows.length) { - container.innerHTML = '
No manifests
'; - return; - } - container.innerHTML = rows.map((m) => `
-
Manifest #${Number(m.id)}
-
- algo: ${esc(m.hash_algo)} - created: ${esc(m.created_at || '')} -
-
`).join(''); - } catch (e) { - notify(e.message, true); - } - } - - async function refreshAll() { - await Promise.all([ - refreshStatus(), - refreshDetections(), - refreshTracks(), - refreshCorrelations(), - refreshIncidents(), - refreshActions(), - refreshManifests(), - ]); - renderMainPane(); - } - - async function startSession(mode) { - try { - await api('/drone-ops/session/start', { - method: 'POST', - body: JSON.stringify({ mode }), - }); - notify(`Started ${mode} session`); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function stopSession() { - try { - await api('/drone-ops/session/stop', { method: 'POST', body: JSON.stringify({}) }); - notify('Session stopped'); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function arm() { - const incident = parseInt(document.getElementById('droneOpsArmIncident')?.value || '0', 10); - const reason = String(document.getElementById('droneOpsArmReason')?.value || '').trim(); - if (!incident || !reason) { - notify('Incident ID and arming reason are required', true); - return; - } - try { - await api('/drone-ops/actions/arm', { - method: 'POST', - body: JSON.stringify({ incident_id: incident, reason }), - }); - notify('Action plane armed'); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function disarm() { - try { - await api('/drone-ops/actions/disarm', { method: 'POST', body: JSON.stringify({}) }); - notify('Action plane disarmed'); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function startDetection() { - const useWifi = Boolean(document.getElementById('droneOpsDetectWifi')?.checked); - const useBluetooth = Boolean(document.getElementById('droneOpsDetectBluetooth')?.checked); - - if (!useWifi && !useBluetooth) { - notify('Select at least one source (WiFi or Bluetooth)', true); - return; - } - - const needsWifiSources = useWifi && !selectHasChoices('droneOpsWifiInterfaceSelect'); - const needsBtSources = useBluetooth && !selectHasChoices('droneOpsBtAdapterSelect'); - if (needsWifiSources || needsBtSources) { - await refreshDetectionSources(true); - } - - applySelectedSourceToModeSelectors(); - await ensureSessionForDetection(); - - const started = []; - const failed = []; - - if (useWifi) { - try { - await startWifiDetection(); - started.push('WiFi'); - } catch (e) { - failed.push(`WiFi: ${e.message}`); - } - } - - if (useBluetooth) { - try { - await startBluetoothDetection(); - started.push('Bluetooth'); - } catch (e) { - failed.push(`Bluetooth: ${e.message}`); - } - } - - updateSensorsState(); - await refreshStatus(); - await refreshDetections(); - await refreshTracks(); - await refreshCorrelations(); - - if (!started.length) { - notify(`Detection start failed (${failed.join(' | ')})`, true); - return; - } - - if (failed.length) { - notify(`Started: ${started.join(', ')} | Errors: ${failed.join(' | ')}`, true); - return; - } - - notify(`Detection started: ${started.join(', ')}`); - } - - async function stopDetection() { - const errors = []; - - if (wifiRunning()) { - try { - await stopWifiDetection(); - } catch (e) { - errors.push(`WiFi: ${e.message}`); - } - } - - if (bluetoothRunning()) { - try { - await stopBluetoothDetection(); - } catch (e) { - errors.push(`Bluetooth: ${e.message}`); - } - } - - await sleep(300); - updateSensorsState(); - await refreshStatus(); - await refreshTracks(); - - if (errors.length) { - notify(`Detection stop issues: ${errors.join(' | ')}`, true); - return; - } - notify('Detection stopped'); - } - - async function createIncident() { - const title = String(document.getElementById('droneOpsIncidentTitle')?.value || '').trim(); - const severity = String(document.getElementById('droneOpsIncidentSeverity')?.value || 'medium'); - if (!title) { - notify('Incident title is required', true); - return; - } - - try { - const data = await api('/drone-ops/incidents', { - method: 'POST', - body: JSON.stringify({ title, severity }), - }); - notify(`Incident #${data.incident?.id || ''} created`); - refreshIncidents(); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function closeIncident(incidentId) { - try { - await api(`/drone-ops/incidents/${incidentId}`, { - method: 'PUT', - body: JSON.stringify({ status: 'closed' }), - }); - notify(`Incident #${incidentId} closed`); - refreshIncidents(); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function openIncidentFromDetection(detectionId) { - const title = `Drone detection #${detectionId}`; - try { - const created = await api('/drone-ops/incidents', { - method: 'POST', - body: JSON.stringify({ title, severity: 'medium' }), - }); - const incidentId = created.incident.id; - await api(`/drone-ops/incidents/${incidentId}/artifacts`, { - method: 'POST', - body: JSON.stringify({ - artifact_type: 'detection', - artifact_ref: String(detectionId), - metadata: { auto_linked: true }, - }), - }); - notify(`Incident #${incidentId} opened for detection #${detectionId}`); - refreshIncidents(); - refreshStatus(); - } catch (e) { - notify(e.message, true); - } - } - - async function attachLatestDetections(incidentId) { - try { - const data = await api('/drone-ops/detections?limit=10&min_confidence=0.6'); - const detections = data.detections || []; - if (!detections.length) { - notify('No high-confidence detections to attach', true); - return; - } - for (const d of detections) { - await api(`/drone-ops/incidents/${incidentId}/artifacts`, { - method: 'POST', - body: JSON.stringify({ - artifact_type: 'detection', - artifact_ref: String(d.id), - metadata: { source: d.source, identifier: d.identifier }, - }), - }); - } - notify(`Attached ${detections.length} detections to incident #${incidentId}`); - refreshIncidents(); - } catch (e) { - notify(e.message, true); - } - } - - async function requestAction() { - const incident = parseInt(document.getElementById('droneOpsActionIncident')?.value || '0', 10); - const actionType = String(document.getElementById('droneOpsActionType')?.value || '').trim(); - - if (!incident || !actionType) { - notify('Incident ID and action type are required', true); - return; - } - - try { - await api('/drone-ops/actions/request', { - method: 'POST', - body: JSON.stringify({ - incident_id: incident, - action_type: actionType, - payload: {}, - }), - }); - notify('Action request submitted'); - refreshActions(); - } catch (e) { - notify(e.message, true); - } - } - - async function approveAction(requestId) { - try { - await api(`/drone-ops/actions/approve/${requestId}`, { - method: 'POST', - body: JSON.stringify({ decision: 'approved' }), - }); - notify(`Request #${requestId} approved`); - refreshActions(); - } catch (e) { - notify(e.message, true); - } - } - - async function executeAction(requestId) { - try { - await api(`/drone-ops/actions/execute/${requestId}`, { - method: 'POST', - body: JSON.stringify({}), - }); - notify(`Request #${requestId} executed`); - refreshActions(); - } catch (e) { - notify(e.message, true); - } - } - - async function generateManifest() { - const incident = parseInt(document.getElementById('droneOpsManifestIncident')?.value || '0', 10); - if (!incident) { - notify('Incident ID is required to generate manifest', true); - return; - } - try { - await api(`/drone-ops/evidence/${incident}/manifest`, { - method: 'POST', - body: JSON.stringify({}), - }); - notify(`Manifest generated for incident #${incident}`); - refreshManifests(); - } catch (e) { - notify(e.message, true); - } - } - - function init() { - if (initialized) { - refreshDetectionSources(true); - refreshAll(); - invalidateMap(); - return; - } - initialized = true; - mapNeedsAutoFit = true; - ensureMap(); - refreshDetectionSources(true); - refreshAll(); - connectStream(); - refreshTimer = setInterval(refreshAll, 15000); - } - - function destroy() { - initialized = false; - mapNeedsAutoFit = true; - if (refreshTimer) { - clearInterval(refreshTimer); - refreshTimer = null; - } - disconnectStream(); - } - - return { - init, - destroy, - refreshStatus, - refreshDetections, - refreshTracks, - refreshDetectionSources, - refreshIncidents, - refreshActions, - startDetection, - stopDetection, - invalidateMap, - startSession, - stopSession, - arm, - disarm, - createIncident, - closeIncident, - openIncidentFromDetection, - attachLatestDetections, - requestAction, - approveAction, - executeAction, - generateManifest, - }; -})(); diff --git a/templates/index.html b/templates/index.html index f677cb4..abe7b92 100644 --- a/templates/index.html +++ b/templates/index.html @@ -66,7 +66,6 @@ aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}", tscm: "{{ url_for('static', filename='css/modes/tscm.css') }}", analytics: "{{ url_for('static', filename='css/modes/analytics.css') }}", - droneops: "{{ url_for('static', filename='css/modes/droneops.css') }}?v={{ version }}&r=droneops6", spystations: "{{ url_for('static', filename='css/modes/spy-stations.css') }}", meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}", sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}", @@ -282,10 +281,6 @@ TSCM - - - - - -
- - - - -
- -
-
Detection Sources
-
- - - -
-
-
Sensors: Idle
- -
- - - - -
- - - -
-

- Detections - -

-
-
- - - -
-
-
No detections yet
-
-
-
- -
-

- Incidents - -

-
-
- - - -
-
-
No incidents
-
-
-
- -
-

- Actions - -

-
-
- - - -
-
-
No action requests
-
-
-
- -
-

- Evidence - -

-
-
- - -
-
-
No manifests
-
-
-
- diff --git a/templates/partials/nav.html b/templates/partials/nav.html index f5140fa..0e5d3f5 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -133,7 +133,6 @@
{{ mode_item('tscm', 'TSCM', '') }} - {{ mode_item('droneops', 'Drone Ops', '') }} {{ mode_item('analytics', 'Analytics', '') }} {{ mode_item('spystations', 'Spy Stations', '') }} {{ mode_item('websdr', 'WebSDR', '') }} @@ -216,7 +215,6 @@ {{ mobile_item('meshtastic', 'Mesh', '') }} {# Intel #} {{ mobile_item('tscm', 'TSCM', '') }} - {{ mobile_item('droneops', 'Drone Ops', '') }} {{ mobile_item('analytics', 'Analytics', '') }} {{ mobile_item('spystations', 'Spy', '') }} {{ mobile_item('websdr', 'WebSDR', '') }} diff --git a/tests/test_drone_ops_policy.py b/tests/test_drone_ops_policy.py deleted file mode 100644 index a58c2ba..0000000 --- a/tests/test_drone_ops_policy.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Tests for Drone Ops policy helpers and service policy behavior.""" - -from utils.drone.policy import required_approvals_for_action -from utils.drone.service import DroneOpsService - - -def test_required_approvals_policy_helper(): - assert required_approvals_for_action('passive_scan') == 1 - assert required_approvals_for_action('wifi_deauth_test') == 2 - - -def test_service_required_approvals_matches_policy_helper(): - assert DroneOpsService.required_approvals('passive_capture') == required_approvals_for_action('passive_capture') - assert DroneOpsService.required_approvals('active_test') == required_approvals_for_action('active_test') - - -def test_service_arm_disarm_policy_state(): - service = DroneOpsService() - - armed = service.arm_actions( - actor='operator-1', - reason='controlled testing', - incident_id=42, - duration_seconds=5, - ) - assert armed['armed'] is True - assert armed['armed_by'] == 'operator-1' - assert armed['arm_reason'] == 'controlled testing' - assert armed['arm_incident_id'] == 42 - assert armed['armed_until'] is not None - - disarmed = service.disarm_actions(actor='operator-1', reason='test complete') - assert disarmed['armed'] is False - assert disarmed['armed_by'] is None - assert disarmed['arm_reason'] is None - assert disarmed['arm_incident_id'] is None diff --git a/tests/test_drone_ops_remote_id.py b/tests/test_drone_ops_remote_id.py deleted file mode 100644 index 7bd5a36..0000000 --- a/tests/test_drone_ops_remote_id.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for Drone Ops Remote ID decoder helpers.""" - -from __future__ import annotations - -import json - -from utils.drone.remote_id import decode_remote_id_payload - - -def test_decode_remote_id_from_dict_payload(): - payload = { - 'remote_id': { - 'uas_id': 'UAS-001', - 'operator_id': 'OP-007', - 'lat': 37.7749, - 'lon': -122.4194, - 'altitude_m': 121.5, - 'speed_mps': 12.3, - 'heading_deg': 270.0, - } - } - - decoded = decode_remote_id_payload(payload) - assert decoded['detected'] is True - assert decoded['source_format'] == 'dict' - assert decoded['uas_id'] == 'UAS-001' - assert decoded['operator_id'] == 'OP-007' - assert decoded['lat'] == 37.7749 - assert decoded['lon'] == -122.4194 - assert decoded['altitude_m'] == 121.5 - assert decoded['speed_mps'] == 12.3 - assert decoded['heading_deg'] == 270.0 - assert decoded['confidence'] > 0.0 - - -def test_decode_remote_id_from_json_string(): - payload = json.dumps({ - 'uas_id': 'RID-ABC', - 'lat': 35.0, - 'lon': -115.0, - 'altitude': 80, - }) - - decoded = decode_remote_id_payload(payload) - assert decoded['detected'] is True - assert decoded['source_format'] == 'json' - assert decoded['uas_id'] == 'RID-ABC' - assert decoded['lat'] == 35.0 - assert decoded['lon'] == -115.0 - assert decoded['altitude_m'] == 80.0 - - -def test_decode_remote_id_from_raw_text_is_not_detected(): - decoded = decode_remote_id_payload('not-a-remote-id-payload') - assert decoded['detected'] is False - assert decoded['source_format'] == 'raw' - assert decoded['uas_id'] is None - assert decoded['operator_id'] is None - assert isinstance(decoded['raw'], dict) - assert decoded['raw']['raw'] == 'not-a-remote-id-payload' diff --git a/tests/test_drone_ops_routes.py b/tests/test_drone_ops_routes.py deleted file mode 100644 index 28e8f84..0000000 --- a/tests/test_drone_ops_routes.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Tests for Drone Ops API routes.""" - -from __future__ import annotations - -import pytest - -import utils.database as db_mod -from utils.drone import get_drone_ops_service - - -def _set_identity(client, role: str, username: str = 'tester') -> None: - with client.session_transaction() as sess: - sess['logged_in'] = True - sess['role'] = role - sess['username'] = username - - -def _clear_drone_tables() -> None: - with db_mod.get_db() as conn: - conn.execute('DELETE FROM action_audit_log') - conn.execute('DELETE FROM action_approvals') - conn.execute('DELETE FROM action_requests') - conn.execute('DELETE FROM evidence_manifests') - conn.execute('DELETE FROM drone_incident_artifacts') - conn.execute('DELETE FROM drone_tracks') - conn.execute('DELETE FROM drone_correlations') - conn.execute('DELETE FROM drone_detections') - conn.execute('DELETE FROM drone_incidents') - conn.execute('DELETE FROM drone_sessions') - - -@pytest.fixture(scope='module', autouse=True) -def isolated_drone_db(tmp_path_factory): - original_db_dir = db_mod.DB_DIR - original_db_path = db_mod.DB_PATH - - tmp_dir = tmp_path_factory.mktemp('drone_ops_db') - db_mod.DB_DIR = tmp_dir - db_mod.DB_PATH = tmp_dir / 'test_intercept.db' - - if hasattr(db_mod._local, 'connection') and db_mod._local.connection is not None: - db_mod._local.connection.close() - db_mod._local.connection = None - - db_mod.init_db() - yield - - db_mod.close_db() - db_mod.DB_DIR = original_db_dir - db_mod.DB_PATH = original_db_path - db_mod._local.connection = None - - -@pytest.fixture(autouse=True) -def clean_drone_state(): - db_mod.init_db() - _clear_drone_tables() - get_drone_ops_service().disarm_actions(actor='test-reset', reason='test setup') - yield - _clear_drone_tables() - get_drone_ops_service().disarm_actions(actor='test-reset', reason='test teardown') - - -def test_start_session_requires_operator_role(client): - _set_identity(client, role='viewer') - response = client.post('/drone-ops/session/start', json={'mode': 'passive'}) - assert response.status_code == 403 - data = response.get_json() - assert data['required_role'] == 'operator' - - -def test_session_lifecycle_and_status(client): - _set_identity(client, role='operator', username='op1') - - started = client.post('/drone-ops/session/start', json={'mode': 'passive'}) - assert started.status_code == 200 - start_data = started.get_json() - assert start_data['status'] == 'success' - assert start_data['session']['mode'] == 'passive' - assert start_data['session']['active'] is True - - status = client.get('/drone-ops/status') - assert status.status_code == 200 - status_data = status.get_json() - assert status_data['status'] == 'success' - assert status_data['active_session'] is not None - assert status_data['active_session']['id'] == start_data['session']['id'] - - stopped = client.post('/drone-ops/session/stop', json={'id': start_data['session']['id']}) - assert stopped.status_code == 200 - stop_data = stopped.get_json() - assert stop_data['status'] == 'success' - assert stop_data['session']['active'] is False - - -def test_detection_ingest_visible_via_endpoint(client): - _set_identity(client, role='operator', username='op1') - start_resp = client.post('/drone-ops/session/start', json={'mode': 'passive'}) - assert start_resp.status_code == 200 - - service = get_drone_ops_service() - service.ingest_event( - mode='wifi', - event={ - 'bssid': '60:60:1F:AA:BB:CC', - 'ssid': 'DJI-OPS-TEST', - }, - event_type='network_update', - ) - - _set_identity(client, role='viewer', username='viewer1') - response = client.get('/drone-ops/detections?source=wifi&min_confidence=0.5') - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'success' - assert data['count'] >= 1 - detection = data['detections'][0] - assert detection['source'] == 'wifi' - assert detection['confidence'] >= 0.5 - - -def test_incident_artifact_and_manifest_flow(client): - _set_identity(client, role='operator', username='op1') - created = client.post( - '/drone-ops/incidents', - json={'title': 'Unidentified UAS', 'severity': 'high'}, - ) - assert created.status_code == 201 - incident = created.get_json()['incident'] - incident_id = incident['id'] - - artifact_resp = client.post( - f'/drone-ops/incidents/{incident_id}/artifacts', - json={'artifact_type': 'detection', 'artifact_ref': '12345'}, - ) - assert artifact_resp.status_code == 201 - - _set_identity(client, role='analyst', username='analyst1') - manifest_resp = client.post(f'/drone-ops/evidence/{incident_id}/manifest', json={}) - assert manifest_resp.status_code == 201 - manifest = manifest_resp.get_json()['manifest'] - assert manifest['manifest']['integrity']['algorithm'] == 'sha256' - assert len(manifest['manifest']['integrity']['digest']) == 64 - - _set_identity(client, role='viewer', username='viewer1') - listed = client.get(f'/drone-ops/evidence/{incident_id}/manifests') - assert listed.status_code == 200 - listed_data = listed.get_json() - assert listed_data['count'] == 1 - assert listed_data['manifests'][0]['id'] == manifest['id'] - - -def test_action_execution_requires_arming_and_two_approvals(client): - _set_identity(client, role='operator', username='op1') - incident_resp = client.post('/drone-ops/incidents', json={'title': 'Action Gate Test'}) - incident_id = incident_resp.get_json()['incident']['id'] - - request_resp = client.post( - '/drone-ops/actions/request', - json={ - 'incident_id': incident_id, - 'action_type': 'wifi_deauth_test', - 'payload': {'target': 'aa:bb:cc:dd:ee:ff'}, - }, - ) - assert request_resp.status_code == 201 - request_id = request_resp.get_json()['request']['id'] - - not_armed_resp = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) - assert not_armed_resp.status_code == 403 - assert 'not armed' in not_armed_resp.get_json()['message'].lower() - - arm_resp = client.post( - '/drone-ops/actions/arm', - json={'incident_id': incident_id, 'reason': 'controlled test'}, - ) - assert arm_resp.status_code == 200 - assert arm_resp.get_json()['policy']['armed'] is True - - insufficient_resp = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) - assert insufficient_resp.status_code == 400 - assert 'insufficient approvals' in insufficient_resp.get_json()['message'].lower() - - _set_identity(client, role='supervisor', username='supervisor-a') - approve_one = client.post(f'/drone-ops/actions/approve/{request_id}', json={'decision': 'approved'}) - assert approve_one.status_code == 200 - - _set_identity(client, role='operator', username='op1') - still_insufficient = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) - assert still_insufficient.status_code == 400 - - _set_identity(client, role='supervisor', username='supervisor-b') - approve_two = client.post(f'/drone-ops/actions/approve/{request_id}', json={'decision': 'approved'}) - assert approve_two.status_code == 200 - assert approve_two.get_json()['request']['status'] == 'approved' - - _set_identity(client, role='operator', username='op1') - executed = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) - assert executed.status_code == 200 - assert executed.get_json()['request']['status'] == 'executed' - - -def test_passive_action_executes_after_single_approval(client): - _set_identity(client, role='operator', username='op1') - incident_resp = client.post('/drone-ops/incidents', json={'title': 'Passive Action Test'}) - incident_id = incident_resp.get_json()['incident']['id'] - - request_resp = client.post( - '/drone-ops/actions/request', - json={'incident_id': incident_id, 'action_type': 'passive_spectrum_capture'}, - ) - request_id = request_resp.get_json()['request']['id'] - - arm_resp = client.post( - '/drone-ops/actions/arm', - json={'incident_id': incident_id, 'reason': 'passive validation'}, - ) - assert arm_resp.status_code == 200 - - _set_identity(client, role='supervisor', username='supervisor-a') - approve_resp = client.post(f'/drone-ops/actions/approve/{request_id}', json={'decision': 'approved'}) - assert approve_resp.status_code == 200 - assert approve_resp.get_json()['request']['status'] == 'approved' - - _set_identity(client, role='operator', username='op1') - execute_resp = client.post(f'/drone-ops/actions/execute/{request_id}', json={}) - assert execute_resp.status_code == 200 - assert execute_resp.get_json()['request']['status'] == 'executed' diff --git a/utils/authz.py b/utils/authz.py deleted file mode 100644 index 086ca27..0000000 --- a/utils/authz.py +++ /dev/null @@ -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 diff --git a/utils/database.py b/utils/database.py index 373fa68..a87c54d 100644 --- a/utils/database.py +++ b/utils/database.py @@ -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 - ] - diff --git a/utils/drone/__init__.py b/utils/drone/__init__.py deleted file mode 100644 index ea344fb..0000000 --- a/utils/drone/__init__.py +++ /dev/null @@ -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', -] diff --git a/utils/drone/detector.py b/utils/drone/detector.py deleted file mode 100644 index 3886bf0..0000000 --- a/utils/drone/detector.py +++ /dev/null @@ -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 [] diff --git a/utils/drone/policy.py b/utils/drone/policy.py deleted file mode 100644 index b927bd0..0000000 --- a/utils/drone/policy.py +++ /dev/null @@ -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 diff --git a/utils/drone/remote_id.py b/utils/drone/remote_id.py deleted file mode 100644 index 47dc604..0000000 --- a/utils/drone/remote_id.py +++ /dev/null @@ -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 diff --git a/utils/drone/service.py b/utils/drone/service.py deleted file mode 100644 index d24bff4..0000000 --- a/utils/drone/service.py +++ /dev/null @@ -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 diff --git a/utils/event_pipeline.py b/utils/event_pipeline.py index 73aa5fd..35fcf53 100644 --- a/utils/event_pipeline.py +++ b/utils/event_pipeline.py @@ -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)