Remove Drone Ops feature end-to-end

This commit is contained in:
Smittix
2026-02-20 17:09:17 +00:00
parent b628a5f751
commit af5b17e841
19 changed files with 2 additions and 5231 deletions

View File

@@ -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/<id>` | `GET` / `PUT` | Incident detail and status updates |
| `/drone-ops/incidents/<id>/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/<id>` | `POST` | Secondary approval (if required) |
| `/drone-ops/actions/execute/<id>` | `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.

View File

@@ -36,7 +36,6 @@ def register_blueprints(app):
from .bt_locate import bt_locate_bp from .bt_locate import bt_locate_bp
from .analytics import analytics_bp from .analytics import analytics_bp
from .space_weather import space_weather_bp from .space_weather import space_weather_bp
from .drone_ops import drone_ops_bp
app.register_blueprint(pager_bp) app.register_blueprint(pager_bp)
app.register_blueprint(sensor_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(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
app.register_blueprint(space_weather_bp) # Space weather monitoring 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 # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module

View File

@@ -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/<int:incident_id>', 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/<int:incident_id>', 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/<int:incident_id>/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/<int:request_id>', 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/<int:request_id>', 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/<int:request_id>', 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/<int:incident_id>/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/<int:manifest_id>', 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/<int:incident_id>/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})

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -66,7 +66,6 @@
aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}", aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}",
tscm: "{{ url_for('static', filename='css/modes/tscm.css') }}", tscm: "{{ url_for('static', filename='css/modes/tscm.css') }}",
analytics: "{{ url_for('static', filename='css/modes/analytics.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') }}", spystations: "{{ url_for('static', filename='css/modes/spy-stations.css') }}",
meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}", meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}",
sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}", sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}",
@@ -282,10 +281,6 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span> <span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<span class="mode-name">TSCM</span> <span class="mode-name">TSCM</span>
</button> </button>
<button class="mode-card mode-card-sm" onclick="selectMode('droneops')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="8" r="2"/><circle cx="5" cy="16" r="2"/><circle cx="19" cy="16" r="2"/></svg></span>
<span class="mode-name">Drone Ops</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('analytics')"> <button class="mode-card mode-card-sm" onclick="selectMode('analytics')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg></span> <span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg></span>
<span class="mode-name">Analytics</span> <span class="mode-name">Analytics</span>
@@ -611,8 +606,6 @@
{% include 'partials/modes/analytics.html' %} {% include 'partials/modes/analytics.html' %}
{% include 'partials/modes/droneops.html' %}
{% include 'partials/modes/ais.html' %} {% include 'partials/modes/ais.html' %}
{% include 'partials/modes/spy-stations.html' %} {% include 'partials/modes/spy-stations.html' %}
@@ -3139,74 +3132,6 @@
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div> <div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
<div id="droneOpsMainPane" class="droneops-main-pane" style="display: none;">
<div class="droneops-main-top">
<div class="droneops-main-map-card">
<div class="droneops-main-card-header">
<h4>Operational Map</h4>
<span id="droneOpsMapMeta" class="droneops-main-card-meta">Waiting for telemetry</span>
</div>
<div id="droneOpsMap" class="droneops-main-map"></div>
</div>
<div class="droneops-main-side">
<div class="droneops-main-card">
<div class="droneops-main-card-header">
<h4>Live Summary</h4>
<span id="droneOpsMainLastUpdate" class="droneops-main-card-meta">--</span>
</div>
<div class="droneops-main-kpis">
<div class="droneops-main-kpi">
<span>Detections</span>
<strong id="droneOpsMainSummaryDetections">0</strong>
</div>
<div class="droneops-main-kpi">
<span>Sources</span>
<strong id="droneOpsMainSummarySources">0</strong>
</div>
<div class="droneops-main-kpi">
<span>Track Points</span>
<strong id="droneOpsMainSummaryTracks">0</strong>
</div>
<div class="droneops-main-kpi">
<span>Telemetry IDs</span>
<strong id="droneOpsMainSummaryTelemetry">0</strong>
</div>
<div class="droneops-main-kpi">
<span>Correlations</span>
<strong id="droneOpsMainSummaryCorrelations">0</strong>
</div>
</div>
</div>
<div class="droneops-main-card">
<div class="droneops-main-card-header">
<h4>Operator Correlations</h4>
</div>
<div id="droneOpsMainCorrelations" class="droneops-main-list compact">
<div class="droneops-empty">No correlations yet</div>
</div>
</div>
</div>
</div>
<div class="droneops-main-bottom">
<div class="droneops-main-card">
<div class="droneops-main-card-header">
<h4>Detections</h4>
</div>
<div id="droneOpsMainDetections" class="droneops-main-list">
<div class="droneops-empty">No detections yet</div>
</div>
</div>
<div class="droneops-main-card">
<div class="droneops-main-card-header">
<h4>Telemetry Stream</h4>
</div>
<div id="droneOpsMainTelemetry" class="droneops-main-list">
<div class="droneops-empty">No telemetry yet</div>
</div>
</div>
</div>
</div>
<div class="output-content signal-feed" id="output"> <div class="output-content signal-feed" id="output">
<div class="placeholder signal-empty-state"> <div class="placeholder signal-empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -3277,7 +3202,6 @@
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script> <script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script> <script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/droneops.js') }}?v={{ version }}&r=droneops6"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<script> <script>
@@ -3429,7 +3353,6 @@
bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' }, bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' },
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' }, meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' }, tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
droneops: { label: 'Drone Ops', indicator: 'DRONE OPS', outputTitle: 'Drone Ops', group: 'intel' },
analytics: { label: 'Analytics', indicator: 'ANALYTICS', outputTitle: 'Cross-Mode Analytics', group: 'intel' }, analytics: { label: 'Analytics', indicator: 'ANALYTICS', outputTitle: 'Cross-Mode Analytics', group: 'intel' },
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' }, spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' }, websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
@@ -4023,7 +3946,6 @@
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr'); document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz'); document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics'); document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics');
document.getElementById('droneOpsMode')?.classList.toggle('active', mode === 'droneops');
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather'); document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
@@ -4065,7 +3987,6 @@
const subghzVisuals = document.getElementById('subghzVisuals'); const subghzVisuals = document.getElementById('subghzVisuals');
const btLocateVisuals = document.getElementById('btLocateVisuals'); const btLocateVisuals = document.getElementById('btLocateVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals'); const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const droneOpsMainPane = document.getElementById('droneOpsMainPane');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none'; if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none'; if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none'; if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -4082,7 +4003,6 @@
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none'; if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none'; if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none'; if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (droneOpsMainPane) droneOpsMainPane.style.display = mode === 'droneops' ? 'flex' : 'none';
// Hide sidebar by default for Meshtastic mode, show for others // Hide sidebar by default for Meshtastic mode, show for others
const mainContent = document.querySelector('.main-content'); const mainContent = document.querySelector('.main-content');
@@ -4124,17 +4044,6 @@
if (typeof Analytics !== 'undefined' && Analytics.destroy) Analytics.destroy(); if (typeof Analytics !== 'undefined' && Analytics.destroy) Analytics.destroy();
} }
// Initialize/destroy Drone Ops mode
if (mode === 'droneops') {
document.querySelectorAll('#droneOpsMode .section.collapsed').forEach(s => s.classList.remove('collapsed'));
if (typeof DroneOps !== 'undefined' && DroneOps.init) {
DroneOps.init();
if (DroneOps.invalidateMap) setTimeout(() => DroneOps.invalidateMap(), 120);
}
} else {
if (typeof DroneOps !== 'undefined' && DroneOps.destroy) DroneOps.destroy();
}
// Initialize/destroy Space Weather mode // Initialize/destroy Space Weather mode
if (mode !== 'spaceweather') { if (mode !== 'spaceweather') {
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy(); if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
@@ -4149,7 +4058,7 @@
const reconBtn = document.getElementById('reconBtn'); const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel'); const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'droneops' || mode === 'spaceweather') { if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') {
if (reconPanel) reconPanel.style.display = 'none'; if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none'; if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none'; if (intelBtn) intelBtn.style.display = 'none';
@@ -4187,7 +4096,7 @@
// Hide output console for modes with their own visualizations // Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output'); const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar'); const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'droneops' || mode === 'spaceweather') ? 'none' : 'block'; if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex'; if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it) // Restore sidebar when leaving Meshtastic mode (user may have collapsed it)

View File

@@ -1,152 +0,0 @@
<!-- DRONE OPS MODE -->
<div id="droneOpsMode" class="mode-content">
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Drone Ops Status</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="droneops-status-grid" id="droneOpsStatusGrid">
<div class="droneops-status-card">
<div class="droneops-status-label">Session</div>
<div class="droneops-status-value" id="droneOpsSessionValue">Idle</div>
</div>
<div class="droneops-status-card">
<div class="droneops-status-label">Armed</div>
<div class="droneops-status-value" id="droneOpsArmedValue">No</div>
</div>
<div class="droneops-status-card">
<div class="droneops-status-label">Detections</div>
<div class="droneops-status-value" id="droneOpsDetectionCount">0</div>
</div>
<div class="droneops-status-card">
<div class="droneops-status-label">Open Incidents</div>
<div class="droneops-status-value" id="droneOpsIncidentCount">0</div>
</div>
</div>
<div class="droneops-row droneops-row--actions">
<button class="preset-btn" onclick="DroneOps.startSession('passive')">Start Passive Session</button>
<button class="preset-btn" onclick="DroneOps.startSession('active')">Start Active Session</button>
<button class="clear-btn" onclick="DroneOps.stopSession()">Stop Session</button>
</div>
<div class="droneops-row droneops-row--actions">
<label class="droneops-check">
<input id="droneOpsDetectWifi" type="checkbox" checked>
<span>WiFi</span>
</label>
<label class="droneops-check">
<input id="droneOpsDetectBluetooth" type="checkbox" checked>
<span>Bluetooth</span>
</label>
<button class="preset-btn" onclick="DroneOps.startDetection()">Start Detection</button>
<button class="clear-btn" onclick="DroneOps.stopDetection()">Stop Detection</button>
</div>
<div class="droneops-source-block">
<div class="droneops-source-title">Detection Sources</div>
<div class="droneops-row droneops-row--sources">
<label class="droneops-field">
<span class="droneops-field-label">WiFi Interface</span>
<select id="droneOpsWifiInterfaceSelect" class="droneops-input" title="WiFi source interface">
<option value="">Auto WiFi source</option>
</select>
</label>
<label class="droneops-field">
<span class="droneops-field-label">Bluetooth Adapter</span>
<select id="droneOpsBtAdapterSelect" class="droneops-input" title="Bluetooth source adapter">
<option value="">Auto Bluetooth source</option>
</select>
</label>
<button class="preset-btn droneops-source-refresh" onclick="DroneOps.refreshDetectionSources()">Refresh Sources</button>
</div>
</div>
<div class="droneops-subtext">Sensors: <span id="droneOpsSensorsState">Idle</span></div>
<div class="droneops-row droneops-row--actions">
<input id="droneOpsArmIncident" class="droneops-input" type="number" placeholder="Incident ID" min="1">
<input id="droneOpsArmReason" class="droneops-input" type="text" placeholder="Arming reason">
<button class="preset-btn" onclick="DroneOps.arm()">Arm Actions</button>
<button class="clear-btn" onclick="DroneOps.disarm()">Disarm</button>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Detections</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="droneops-row">
<select id="droneOpsSourceFilter" class="droneops-input" onchange="DroneOps.refreshDetections()">
<option value="">All Sources</option>
<option value="wifi">WiFi</option>
<option value="bluetooth">Bluetooth</option>
<option value="rf">RF</option>
</select>
<input id="droneOpsConfidenceFilter" class="droneops-input" type="number" min="0" max="1" step="0.05" value="0.5" placeholder="Min confidence">
<button class="preset-btn" onclick="DroneOps.refreshDetections()">Refresh</button>
</div>
<div id="droneOpsDetections" class="droneops-list">
<div class="droneops-empty">No detections yet</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Incidents</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="droneops-row">
<input id="droneOpsIncidentTitle" class="droneops-input" type="text" placeholder="Incident title">
<select id="droneOpsIncidentSeverity" class="droneops-input">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
<button class="preset-btn" onclick="DroneOps.createIncident()">Create Incident</button>
</div>
<div id="droneOpsIncidents" class="droneops-list">
<div class="droneops-empty">No incidents</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Actions</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="droneops-row">
<input id="droneOpsActionIncident" class="droneops-input" type="number" placeholder="Incident ID" min="1">
<input id="droneOpsActionType" class="droneops-input" type="text" placeholder="Action type (e.g. wifi_deauth_test)">
<button class="preset-btn" onclick="DroneOps.requestAction()">Request Action</button>
</div>
<div id="droneOpsActions" class="droneops-list">
<div class="droneops-empty">No action requests</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Evidence</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="droneops-row">
<input id="droneOpsManifestIncident" class="droneops-input" type="number" placeholder="Incident ID" min="1">
<button class="preset-btn" onclick="DroneOps.generateManifest()">Generate Manifest</button>
</div>
<div id="droneOpsManifests" class="droneops-list">
<div class="droneops-empty">No manifests</div>
</div>
</div>
</div>
</div>

View File

@@ -133,7 +133,6 @@
<div class="mode-nav-dropdown-menu"> <div class="mode-nav-dropdown-menu">
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }} {{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{{ mode_item('droneops', 'Drone Ops', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="8" r="2"/><circle cx="5" cy="16" r="2"/><circle cx="19" cy="16" r="2"/><path d="M9 9 7 8"/><path d="m15 9 2-1"/><path d="m9 15-2 1"/><path d="m15 15 2 1"/></svg>') }}
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }} {{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }} {{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }} {{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
@@ -216,7 +215,6 @@
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }} {{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{# Intel #} {# Intel #}
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }} {{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{{ mobile_item('droneops', 'Drone Ops', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><circle cx="5" cy="8" r="2"/><circle cx="19" cy="8" r="2"/><circle cx="5" cy="16" r="2"/><circle cx="19" cy="16" r="2"/></svg>') }}
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }} {{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }} {{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }} {{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}

View File

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

View File

@@ -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'

View File

@@ -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'

View File

@@ -1,74 +0,0 @@
"""Authorization helpers for role-based and arming-gated operations."""
from __future__ import annotations
from functools import wraps
from typing import Any, Callable
from flask import jsonify, session
ROLE_LEVELS: dict[str, int] = {
'viewer': 10,
'analyst': 20,
'operator': 30,
'supervisor': 40,
'admin': 50,
}
def current_username() -> str:
"""Get current username from session."""
return str(session.get('username') or 'anonymous')
def current_role() -> str:
"""Get current role from session with safe default."""
role = str(session.get('role') or 'viewer').strip().lower()
return role if role in ROLE_LEVELS else 'viewer'
def has_role(required_role: str) -> bool:
"""Return True if current session role satisfies required role."""
required = ROLE_LEVELS.get(required_role, ROLE_LEVELS['admin'])
actual = ROLE_LEVELS.get(current_role(), ROLE_LEVELS['viewer'])
return actual >= required
def require_role(required_role: str) -> Callable:
"""Decorator enforcing minimum role."""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any):
if not has_role(required_role):
return jsonify({
'status': 'error',
'message': f'{required_role} role required',
'required_role': required_role,
'current_role': current_role(),
}), 403
return func(*args, **kwargs)
return wrapper
return decorator
def require_armed(func: Callable) -> Callable:
"""Decorator enforcing armed state for active actions."""
@wraps(func)
def wrapper(*args: Any, **kwargs: Any):
from utils.drone import get_drone_ops_service
service = get_drone_ops_service()
policy = service.get_policy_state()
if not policy.get('armed'):
return jsonify({
'status': 'error',
'message': 'Action plane is not armed',
'policy': policy,
}), 403
return func(*args, **kwargs)
return wrapper

View File

@@ -555,172 +555,6 @@ def init_db() -> None:
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1) 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") logger.info("Database initialized successfully")
@@ -2469,806 +2303,3 @@ def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
return True, 'Removed' 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
]

View File

@@ -1,10 +0,0 @@
"""Drone Ops utility package."""
from .service import DroneOpsService, get_drone_ops_service
from .remote_id import decode_remote_id_payload
__all__ = [
'DroneOpsService',
'get_drone_ops_service',
'decode_remote_id_payload',
]

View File

@@ -1,305 +0,0 @@
"""Heuristics for identifying drone-related emissions across WiFi/BLE/RF feeds."""
from __future__ import annotations
import re
from typing import Any
from utils.drone.remote_id import decode_remote_id_payload
SSID_PATTERNS = [
re.compile(r'(^|[-_\s])(dji|mavic|phantom|inspire|matrice|mini)([-_\s]|$)', re.IGNORECASE),
re.compile(r'(^|[-_\s])(parrot|anafi|bebop)([-_\s]|$)', re.IGNORECASE),
re.compile(r'(^|[-_\s])(autel|evo)([-_\s]|$)', re.IGNORECASE),
re.compile(r'(^|[-_\s])(skydio|yuneec)([-_\s]|$)', re.IGNORECASE),
re.compile(r'(^|[-_\s])(uas|uav|drone|rid|opendroneid)([-_\s]|$)', re.IGNORECASE),
]
DRONE_OUI_PREFIXES = {
'60:60:1F': 'DJI',
'90:3A:E6': 'DJI',
'34:D2:62': 'DJI',
'90:3A:AF': 'DJI',
'00:12:1C': 'Parrot',
'90:03:B7': 'Parrot',
'48:1C:B9': 'Autel',
'AC:89:95': 'Skydio',
}
BT_NAME_PATTERNS = [
re.compile(r'(dji|mavic|phantom|inspire|matrice|mini)', re.IGNORECASE),
re.compile(r'(parrot|anafi|bebop)', re.IGNORECASE),
re.compile(r'(autel|evo)', re.IGNORECASE),
re.compile(r'(skydio|yuneec)', re.IGNORECASE),
re.compile(r'(remote\s?id|opendroneid|uas|uav|drone)', re.IGNORECASE),
]
REMOTE_ID_UUID_HINTS = {'fffa', 'faff', 'fffb'}
RF_FREQ_HINTS_MHZ = (315.0, 433.92, 868.0, 915.0, 1200.0, 2400.0, 5800.0)
def _normalize_mac(value: Any) -> str:
text = str(value or '').strip().upper().replace('-', ':')
if len(text) >= 8:
return text
return ''
def _extract_wifi_event(event: dict) -> dict | None:
if not isinstance(event, dict):
return None
if isinstance(event.get('network'), dict):
return event['network']
if event.get('type') == 'network_update' and isinstance(event.get('network'), dict):
return event['network']
if any(k in event for k in ('bssid', 'essid', 'ssid')):
return event
return None
def _extract_bt_event(event: dict) -> dict | None:
if not isinstance(event, dict):
return None
if isinstance(event.get('device'), dict):
return event['device']
if any(k in event for k in ('device_id', 'address', 'name', 'manufacturer_name', 'service_uuids')):
return event
return None
def _extract_frequency_mhz(event: dict) -> float | None:
if not isinstance(event, dict):
return None
candidates = [
event.get('frequency_mhz'),
event.get('frequency'),
]
if 'frequency_hz' in event:
try:
candidates.append(float(event['frequency_hz']) / 1_000_000.0)
except (TypeError, ValueError):
pass
for value in candidates:
try:
if value is None:
continue
f = float(value)
if f > 100000: # likely in Hz
f = f / 1_000_000.0
if 1.0 <= f <= 7000.0:
return round(f, 6)
except (TypeError, ValueError):
continue
text = str(event.get('text') or event.get('message') or '')
match = re.search(r'([0-9]{2,4}(?:\.[0-9]+)?)\s*MHz', text, flags=re.IGNORECASE)
if match:
try:
return float(match.group(1))
except ValueError:
return None
return None
def _closest_freq_delta(freq_mhz: float) -> float:
return min(abs(freq_mhz - hint) for hint in RF_FREQ_HINTS_MHZ)
def _maybe_track_from_remote_id(remote_id: dict, source: str) -> dict | None:
if not remote_id.get('detected'):
return None
lat = remote_id.get('lat')
lon = remote_id.get('lon')
if lat is None or lon is None:
return None
return {
'lat': lat,
'lon': lon,
'altitude_m': remote_id.get('altitude_m'),
'speed_mps': remote_id.get('speed_mps'),
'heading_deg': remote_id.get('heading_deg'),
'quality': remote_id.get('confidence', 0.0),
'source': source,
}
def _detect_wifi(event: dict) -> list[dict]:
network = _extract_wifi_event(event)
if not network:
return []
bssid = _normalize_mac(network.get('bssid') or network.get('mac') or network.get('id'))
ssid = str(network.get('essid') or network.get('ssid') or network.get('display_name') or '').strip()
identifier = bssid or ssid
if not identifier:
return []
score = 0.0
reasons: list[str] = []
if ssid:
for pattern in SSID_PATTERNS:
if pattern.search(ssid):
score += 0.45
reasons.append('ssid_pattern')
break
if bssid and len(bssid) >= 8:
prefix = bssid[:8]
if prefix in DRONE_OUI_PREFIXES:
score += 0.45
reasons.append(f'known_oui:{DRONE_OUI_PREFIXES[prefix]}')
remote_id = decode_remote_id_payload(network)
if remote_id.get('detected'):
score = max(score, 0.75)
reasons.append('remote_id_payload')
if score < 0.5:
return []
confidence = min(1.0, round(score, 3))
classification = 'wifi_drone_remote_id' if remote_id.get('detected') else 'wifi_drone_signature'
return [{
'source': 'wifi',
'identifier': identifier,
'classification': classification,
'confidence': confidence,
'payload': {
'network': network,
'reasons': reasons,
'brand_hint': DRONE_OUI_PREFIXES.get(bssid[:8]) if bssid else None,
},
'remote_id': remote_id if remote_id.get('detected') else None,
'track': _maybe_track_from_remote_id(remote_id, 'wifi'),
}]
def _detect_bluetooth(event: dict) -> list[dict]:
device = _extract_bt_event(event)
if not device:
return []
address = _normalize_mac(device.get('address') or device.get('mac'))
device_id = str(device.get('device_id') or '').strip()
name = str(device.get('name') or '').strip()
manufacturer = str(device.get('manufacturer_name') or '').strip()
identifier = address or device_id or name
if not identifier:
return []
score = 0.0
reasons: list[str] = []
haystack = f'{name} {manufacturer}'.strip()
if haystack:
for pattern in BT_NAME_PATTERNS:
if pattern.search(haystack):
score += 0.55
reasons.append('name_or_vendor_pattern')
break
uuids = device.get('service_uuids') or []
for uuid in uuids:
if str(uuid).replace('-', '').lower()[-4:] in REMOTE_ID_UUID_HINTS:
score = max(score, 0.7)
reasons.append('remote_id_service_uuid')
break
tracker = device.get('tracker') if isinstance(device.get('tracker'), dict) else {}
if tracker.get('is_tracker') and 'drone' in str(tracker.get('type') or '').lower():
score = max(score, 0.7)
reasons.append('tracker_engine_drone_label')
remote_id = decode_remote_id_payload(device)
if remote_id.get('detected'):
score = max(score, 0.75)
reasons.append('remote_id_payload')
if score < 0.55:
return []
confidence = min(1.0, round(score, 3))
classification = 'bluetooth_drone_remote_id' if remote_id.get('detected') else 'bluetooth_drone_signature'
return [{
'source': 'bluetooth',
'identifier': identifier,
'classification': classification,
'confidence': confidence,
'payload': {
'device': device,
'reasons': reasons,
},
'remote_id': remote_id if remote_id.get('detected') else None,
'track': _maybe_track_from_remote_id(remote_id, 'bluetooth'),
}]
def _detect_rf(event: dict) -> list[dict]:
if not isinstance(event, dict):
return []
freq_mhz = _extract_frequency_mhz(event)
if freq_mhz is None:
return []
delta = _closest_freq_delta(freq_mhz)
if delta > 35.0:
return []
score = max(0.5, 0.85 - (delta / 100.0))
confidence = min(1.0, round(score, 3))
event_id = str(event.get('capture_id') or event.get('id') or f'{freq_mhz:.3f}MHz')
identifier = f'rf:{event_id}'
payload = {
'event': event,
'frequency_mhz': freq_mhz,
'delta_from_known_band_mhz': round(delta, 3),
'known_bands_mhz': list(RF_FREQ_HINTS_MHZ),
}
return [{
'source': 'rf',
'identifier': identifier,
'classification': 'rf_drone_link_activity',
'confidence': confidence,
'payload': payload,
'remote_id': None,
'track': None,
}]
def detect_from_event(mode: str, event: dict, event_type: str | None = None) -> list[dict]:
"""Detect drone-relevant signals from a normalized mode event."""
mode_lower = str(mode or '').lower()
if mode_lower.startswith('wifi'):
return _detect_wifi(event)
if mode_lower.startswith('bluetooth') or mode_lower.startswith('bt'):
return _detect_bluetooth(event)
if mode_lower in {'subghz', 'listening_scanner', 'waterfall', 'listening'}:
return _detect_rf(event)
# Opportunistic decode from any feed that carries explicit remote ID payloads.
remote_id = decode_remote_id_payload(event)
if remote_id.get('detected'):
identifier = str(remote_id.get('uas_id') or remote_id.get('operator_id') or 'remote_id')
return [{
'source': mode_lower or 'unknown',
'identifier': identifier,
'classification': 'remote_id_detected',
'confidence': float(remote_id.get('confidence') or 0.6),
'payload': {'event': event, 'event_type': event_type},
'remote_id': remote_id,
'track': _maybe_track_from_remote_id(remote_id, mode_lower or 'unknown'),
}]
return []

View File

@@ -1,11 +0,0 @@
"""Drone Ops policy helpers."""
from __future__ import annotations
def required_approvals_for_action(action_type: str) -> int:
"""Return required approvals for a given action type."""
action = (action_type or '').strip().lower()
if action.startswith('passive_'):
return 1
return 2

View File

@@ -1,121 +0,0 @@
"""Remote ID payload normalization and lightweight decoding helpers."""
from __future__ import annotations
import json
from typing import Any
DRONE_ID_KEYS = ('uas_id', 'drone_id', 'serial_number', 'serial', 'id', 'uasId')
OPERATOR_ID_KEYS = ('operator_id', 'pilot_id', 'operator', 'operatorId')
LAT_KEYS = ('lat', 'latitude')
LON_KEYS = ('lon', 'lng', 'longitude')
ALT_KEYS = ('alt', 'altitude', 'altitude_m', 'height')
SPEED_KEYS = ('speed', 'speed_mps', 'ground_speed')
HEADING_KEYS = ('heading', 'heading_deg', 'course')
def _get_nested(data: dict, *paths: str) -> Any:
for path in paths:
current: Any = data
valid = True
for part in path.split('.'):
if not isinstance(current, dict) or part not in current:
valid = False
break
current = current[part]
if valid:
return current
return None
def _coerce_float(value: Any) -> float | None:
try:
if value is None or value == '':
return None
return float(value)
except (TypeError, ValueError):
return None
def _pick(data: dict, keys: tuple[str, ...], nested_prefixes: tuple[str, ...] = ()) -> Any:
for key in keys:
if key in data:
return data.get(key)
for prefix in nested_prefixes:
for key in keys:
hit = _get_nested(data, f'{prefix}.{key}')
if hit is not None:
return hit
return None
def _normalize_input(payload: Any) -> tuple[dict, str]:
if isinstance(payload, dict):
return payload, 'dict'
if isinstance(payload, bytes):
text = payload.decode('utf-8', errors='replace').strip()
else:
text = str(payload or '').strip()
if not text:
return {}, 'empty'
# JSON-first parsing.
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return parsed, 'json'
except json.JSONDecodeError:
pass
# Keep opaque string payload available to caller.
return {'raw': text}, 'raw'
def decode_remote_id_payload(payload: Any) -> dict:
"""Decode/normalize Remote ID-like payload into a common shape."""
data, fmt = _normalize_input(payload)
drone_id = _pick(data, DRONE_ID_KEYS, ('remote_id', 'message', 'uas'))
operator_id = _pick(data, OPERATOR_ID_KEYS, ('remote_id', 'message', 'operator'))
lat = _coerce_float(_pick(data, LAT_KEYS, ('remote_id', 'message', 'position')))
lon = _coerce_float(_pick(data, LON_KEYS, ('remote_id', 'message', 'position')))
altitude_m = _coerce_float(_pick(data, ALT_KEYS, ('remote_id', 'message', 'position')))
speed_mps = _coerce_float(_pick(data, SPEED_KEYS, ('remote_id', 'message', 'position')))
heading_deg = _coerce_float(_pick(data, HEADING_KEYS, ('remote_id', 'message', 'position')))
confidence = 0.0
if drone_id:
confidence += 0.35
if lat is not None and lon is not None:
confidence += 0.35
if altitude_m is not None:
confidence += 0.15
if operator_id:
confidence += 0.15
confidence = min(1.0, round(confidence, 3))
detected = bool(drone_id or (lat is not None and lon is not None and confidence >= 0.35))
normalized = {
'detected': detected,
'source_format': fmt,
'uas_id': str(drone_id).strip() if drone_id else None,
'operator_id': str(operator_id).strip() if operator_id else None,
'lat': lat,
'lon': lon,
'altitude_m': altitude_m,
'speed_mps': speed_mps,
'heading_deg': heading_deg,
'confidence': confidence,
'raw': data,
}
# Remove heavy raw payload if we successfully extracted structure.
if detected and isinstance(data, dict) and len(data) > 0:
normalized['raw'] = data
return normalized

View File

@@ -1,621 +0,0 @@
"""Stateful Drone Ops service: ingestion, policy, incidents, actions, and evidence."""
from __future__ import annotations
import hashlib
import json
import queue
import threading
import time
from datetime import datetime, timezone
from typing import Any, Generator
import app as app_module
from utils.correlation import get_correlations as get_wifi_bt_correlations
from utils.database import (
add_action_approval,
add_action_audit_log,
add_drone_correlation,
add_drone_incident_artifact,
add_drone_track,
create_action_request,
create_drone_incident,
create_drone_session,
create_evidence_manifest,
get_action_request,
get_active_drone_session,
get_drone_detection,
get_drone_incident,
get_drone_session,
get_evidence_manifest,
list_action_audit_logs,
list_action_requests,
list_drone_correlations,
list_drone_detections,
list_drone_incidents,
list_drone_sessions,
list_drone_tracks,
list_evidence_manifests,
stop_drone_session,
update_action_request_status,
update_drone_incident,
upsert_drone_detection,
)
from utils.drone.detector import detect_from_event
from utils.drone.remote_id import decode_remote_id_payload
from utils.trilateration import estimate_location_from_observations
class DroneOpsService:
"""Orchestrates Drone Ops data and policy controls."""
def __init__(self) -> None:
self._subscribers: set[queue.Queue] = set()
self._subs_lock = threading.Lock()
self._policy_lock = threading.Lock()
self._armed_until_ts: float | None = None
self._armed_by: str | None = None
self._arm_reason: str | None = None
self._arm_incident_id: int | None = None
# ------------------------------------------------------------------
# Streaming
# ------------------------------------------------------------------
@staticmethod
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _emit(self, event_type: str, payload: dict) -> None:
envelope = {
'type': event_type,
'timestamp': self._utc_now_iso(),
'payload': payload,
}
with self._subs_lock:
subscribers = tuple(self._subscribers)
for sub in subscribers:
try:
sub.put_nowait(envelope)
except queue.Full:
try:
sub.get_nowait()
sub.put_nowait(envelope)
except (queue.Empty, queue.Full):
continue
def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]:
"""Yield Drone Ops events for SSE streaming."""
client_queue: queue.Queue = queue.Queue(maxsize=500)
with self._subs_lock:
self._subscribers.add(client_queue)
try:
while True:
try:
yield client_queue.get(timeout=timeout)
except queue.Empty:
yield {'type': 'keepalive', 'timestamp': self._utc_now_iso(), 'payload': {}}
finally:
with self._subs_lock:
self._subscribers.discard(client_queue)
# ------------------------------------------------------------------
# Policy / arming
# ------------------------------------------------------------------
def _policy_state_locked(self) -> dict:
armed = self._armed_until_ts is not None and time.time() < self._armed_until_ts
if not armed:
self._armed_until_ts = None
self._armed_by = None
self._arm_reason = None
self._arm_incident_id = None
return {
'armed': armed,
'armed_by': self._armed_by,
'arm_reason': self._arm_reason,
'arm_incident_id': self._arm_incident_id,
'armed_until': datetime.fromtimestamp(self._armed_until_ts, tz=timezone.utc).isoformat() if self._armed_until_ts else None,
'required_approvals_default': 2,
}
def get_policy_state(self) -> dict:
"""Get current policy and arming state."""
with self._policy_lock:
return self._policy_state_locked()
def arm_actions(
self,
actor: str,
reason: str,
incident_id: int,
duration_seconds: int = 900,
) -> dict:
"""Arm action plane for a bounded duration."""
duration_seconds = max(60, min(7200, int(duration_seconds or 900)))
with self._policy_lock:
self._armed_until_ts = time.time() + duration_seconds
self._armed_by = actor
self._arm_reason = reason
self._arm_incident_id = incident_id
state = self._policy_state_locked()
self._emit('policy_armed', {'actor': actor, 'reason': reason, 'incident_id': incident_id, 'state': state})
return state
def disarm_actions(self, actor: str, reason: str | None = None) -> dict:
"""Disarm action plane."""
with self._policy_lock:
self._armed_until_ts = None
self._armed_by = None
self._arm_reason = None
self._arm_incident_id = None
state = self._policy_state_locked()
self._emit('policy_disarmed', {'actor': actor, 'reason': reason, 'state': state})
return state
@staticmethod
def required_approvals(action_type: str) -> int:
"""Compute required approvals for an action type."""
action = (action_type or '').strip().lower()
if action.startswith('passive_'):
return 1
return 2
# ------------------------------------------------------------------
# Sessions and detections
# ------------------------------------------------------------------
def start_session(
self,
mode: str,
label: str | None,
operator: str,
metadata: dict | None = None,
) -> dict:
"""Start a Drone Ops session."""
active = get_active_drone_session()
if active:
return active
session_id = create_drone_session(
mode=mode or 'passive',
label=label,
operator=operator,
metadata=metadata,
)
session = get_drone_session(session_id)
if session:
self._emit('session_started', {'session': session})
return session or {}
def stop_session(
self,
operator: str,
session_id: int | None = None,
summary: dict | None = None,
) -> dict | None:
"""Stop a Drone Ops session."""
active = get_active_drone_session()
target_id = session_id or (active['id'] if active else None)
if not target_id:
return None
if summary is None:
summary = {
'operator': operator,
'stopped_at': self._utc_now_iso(),
'detections': len(list_drone_detections(session_id=target_id, limit=1000)),
}
stop_drone_session(target_id, summary=summary)
session = get_drone_session(target_id)
if session:
self._emit('session_stopped', {'session': session})
return session
def get_status(self) -> dict:
"""Get full Drone Ops status payload."""
return {
'status': 'success',
'active_session': get_active_drone_session(),
'policy': self.get_policy_state(),
'counts': {
'detections': len(list_drone_detections(limit=1000)),
'incidents_open': len(list_drone_incidents(status='open', limit=1000)),
'actions_pending': len(list_action_requests(status='pending', limit=1000)),
},
}
def ingest_event(self, mode: str, event: dict, event_type: str | None = None) -> None:
"""Ingest cross-mode event and produce Drone Ops detections."""
try:
detections = detect_from_event(mode, event, event_type)
except Exception:
return
if not detections:
return
active = get_active_drone_session()
session_id = active['id'] if active else None
for detection in detections:
try:
detection_id = upsert_drone_detection(
session_id=session_id,
source=detection['source'],
identifier=detection['identifier'],
classification=detection.get('classification'),
confidence=float(detection.get('confidence') or 0.0),
payload=detection.get('payload') or {},
remote_id=detection.get('remote_id') or None,
)
row = get_drone_detection(detection_id)
track = detection.get('track') or {}
if row and track and track.get('lat') is not None and track.get('lon') is not None:
add_drone_track(
detection_id=row['id'],
lat=track.get('lat'),
lon=track.get('lon'),
altitude_m=track.get('altitude_m'),
speed_mps=track.get('speed_mps'),
heading_deg=track.get('heading_deg'),
quality=track.get('quality'),
source=track.get('source') or detection.get('source'),
)
remote_id = detection.get('remote_id') or {}
uas_id = remote_id.get('uas_id')
operator_id = remote_id.get('operator_id')
if uas_id and operator_id:
add_drone_correlation(
drone_identifier=str(uas_id),
operator_identifier=str(operator_id),
method='remote_id_binding',
confidence=float(remote_id.get('confidence') or 0.8),
evidence={
'source': detection.get('source'),
'event_type': event_type,
'detection_id': row['id'] if row else None,
},
)
if row:
self._emit('detection', {
'mode': mode,
'event_type': event_type,
'detection': row,
})
except Exception:
continue
def decode_remote_id(self, payload: Any) -> dict:
"""Decode an explicit Remote ID payload."""
decoded = decode_remote_id_payload(payload)
self._emit('remote_id_decoded', {'decoded': decoded})
return decoded
# ------------------------------------------------------------------
# Queries
# ------------------------------------------------------------------
def get_detections(
self,
session_id: int | None = None,
source: str | None = None,
min_confidence: float = 0.0,
limit: int = 200,
) -> list[dict]:
return list_drone_detections(
session_id=session_id,
source=source,
min_confidence=min_confidence,
limit=limit,
)
def get_tracks(
self,
detection_id: int | None = None,
identifier: str | None = None,
limit: int = 1000,
) -> list[dict]:
return list_drone_tracks(
detection_id=detection_id,
identifier=identifier,
limit=limit,
)
def estimate_geolocation(self, observations: list[dict], environment: str = 'outdoor') -> dict | None:
"""Estimate location from observations."""
return estimate_location_from_observations(observations, environment=environment)
def refresh_correlations(self, min_confidence: float = 0.6) -> list[dict]:
"""Refresh and persist likely drone/operator correlations from WiFi<->BT pairs."""
wifi_devices = dict(app_module.wifi_networks)
wifi_devices.update(dict(app_module.wifi_clients))
bt_devices = dict(app_module.bt_devices)
pairs = get_wifi_bt_correlations(
wifi_devices=wifi_devices,
bt_devices=bt_devices,
min_confidence=min_confidence,
include_historical=True,
)
detections = list_drone_detections(min_confidence=0.5, limit=1000)
known_ids = {d['identifier'].upper() for d in detections}
for pair in pairs:
wifi_mac = str(pair.get('wifi_mac') or '').upper()
bt_mac = str(pair.get('bt_mac') or '').upper()
if wifi_mac in known_ids or bt_mac in known_ids:
add_drone_correlation(
drone_identifier=wifi_mac if wifi_mac in known_ids else bt_mac,
operator_identifier=bt_mac if wifi_mac in known_ids else wifi_mac,
method='wifi_bt_correlation',
confidence=float(pair.get('confidence') or 0.0),
evidence=pair,
)
return list_drone_correlations(min_confidence=min_confidence, limit=200)
# ------------------------------------------------------------------
# Incidents and artifacts
# ------------------------------------------------------------------
def create_incident(
self,
title: str,
severity: str,
opened_by: str,
summary: str | None,
metadata: dict | None,
) -> dict:
incident_id = create_drone_incident(
title=title,
severity=severity,
opened_by=opened_by,
summary=summary,
metadata=metadata,
)
incident = get_drone_incident(incident_id) or {'id': incident_id}
self._emit('incident_created', {'incident': incident})
return incident
def update_incident(
self,
incident_id: int,
status: str | None = None,
severity: str | None = None,
summary: str | None = None,
metadata: dict | None = None,
) -> dict | None:
update_drone_incident(
incident_id=incident_id,
status=status,
severity=severity,
summary=summary,
metadata=metadata,
)
incident = get_drone_incident(incident_id)
if incident:
self._emit('incident_updated', {'incident': incident})
return incident
def add_incident_artifact(
self,
incident_id: int,
artifact_type: str,
artifact_ref: str,
added_by: str,
metadata: dict | None = None,
) -> dict:
artifact_id = add_drone_incident_artifact(
incident_id=incident_id,
artifact_type=artifact_type,
artifact_ref=artifact_ref,
added_by=added_by,
metadata=metadata,
)
artifact = {
'id': artifact_id,
'incident_id': incident_id,
'artifact_type': artifact_type,
'artifact_ref': artifact_ref,
'added_by': added_by,
'metadata': metadata or {},
}
self._emit('incident_artifact_added', {'artifact': artifact})
return artifact
# ------------------------------------------------------------------
# Actions and approvals
# ------------------------------------------------------------------
def request_action(
self,
incident_id: int,
action_type: str,
requested_by: str,
payload: dict | None,
) -> dict | None:
request_id = create_action_request(
incident_id=incident_id,
action_type=action_type,
requested_by=requested_by,
payload=payload,
)
add_action_audit_log(
request_id=request_id,
event_type='requested',
actor=requested_by,
details={'payload': payload or {}},
)
req = get_action_request(request_id)
if req:
req['required_approvals'] = self.required_approvals(req['action_type'])
self._emit('action_requested', {'request': req})
return req
def approve_action(
self,
request_id: int,
approver: str,
decision: str = 'approved',
notes: str | None = None,
) -> dict | None:
req = get_action_request(request_id)
if not req:
return None
approvals = req.get('approvals', [])
if any((a.get('approved_by') or '').lower() == approver.lower() for a in approvals):
return req
add_action_approval(request_id=request_id, approved_by=approver, decision=decision, notes=notes)
add_action_audit_log(
request_id=request_id,
event_type='approval',
actor=approver,
details={'decision': decision, 'notes': notes},
)
req = get_action_request(request_id)
if not req:
return None
approvals = req.get('approvals', [])
approved_count = len([a for a in approvals if str(a.get('decision')).lower() == 'approved'])
required = self.required_approvals(req['action_type'])
if decision.lower() == 'rejected':
update_action_request_status(request_id, status='rejected')
elif approved_count >= required and req.get('status') not in {'executed', 'rejected'}:
update_action_request_status(request_id, status='approved')
req = get_action_request(request_id)
if req:
req['required_approvals'] = required
req['approved_count'] = approved_count
self._emit('action_approved', {'request': req})
return req
def execute_action(self, request_id: int, actor: str) -> tuple[dict | None, str | None]:
"""Execute an approved action request (policy-gated)."""
req = get_action_request(request_id)
if not req:
return None, 'Action request not found'
policy = self.get_policy_state()
if not policy.get('armed'):
return None, 'Action plane is not armed'
approvals = req.get('approvals', [])
approved_count = len([a for a in approvals if str(a.get('decision')).lower() == 'approved'])
required = self.required_approvals(req['action_type'])
if approved_count < required:
return None, f'Insufficient approvals ({approved_count}/{required})'
update_action_request_status(request_id, status='executed', executed_by=actor)
add_action_audit_log(
request_id=request_id,
event_type='executed',
actor=actor,
details={
'dispatch': 'framework',
'note': 'Execution recorded. Attach route-specific handlers per action_type.',
},
)
req = get_action_request(request_id)
if req:
req['required_approvals'] = required
req['approved_count'] = approved_count
self._emit('action_executed', {'request': req})
return req, None
# ------------------------------------------------------------------
# Evidence and manifests
# ------------------------------------------------------------------
def generate_evidence_manifest(
self,
incident_id: int,
created_by: str,
signature: str | None = None,
) -> dict | None:
"""Build and persist an evidence manifest for an incident."""
incident = get_drone_incident(incident_id)
if not incident:
return None
action_requests = list_action_requests(incident_id=incident_id, limit=1000)
request_ids = [r['id'] for r in action_requests]
action_audit: list[dict] = []
for request_id in request_ids:
action_audit.extend(list_action_audit_logs(request_id=request_id, limit=500))
manifest_core = {
'generated_at': self._utc_now_iso(),
'incident': {
'id': incident['id'],
'title': incident['title'],
'status': incident['status'],
'severity': incident['severity'],
'opened_at': incident['opened_at'],
'closed_at': incident['closed_at'],
},
'artifact_count': len(incident.get('artifacts', [])),
'action_request_count': len(action_requests),
'audit_event_count': len(action_audit),
'artifacts': incident.get('artifacts', []),
'action_requests': action_requests,
'action_audit': action_audit,
}
canonical = json.dumps(manifest_core, sort_keys=True, separators=(',', ':'))
sha256_hex = hashlib.sha256(canonical.encode('utf-8')).hexdigest()
manifest = {
**manifest_core,
'integrity': {
'algorithm': 'sha256',
'digest': sha256_hex,
},
}
manifest_id = create_evidence_manifest(
incident_id=incident_id,
manifest=manifest,
hash_algo='sha256',
signature=signature,
created_by=created_by,
)
stored = get_evidence_manifest(manifest_id)
if stored:
self._emit('evidence_manifest_created', {'manifest': stored})
return stored
_drone_service: DroneOpsService | None = None
_drone_service_lock = threading.Lock()
def get_drone_ops_service() -> DroneOpsService:
"""Get global Drone Ops service singleton."""
global _drone_service
with _drone_service_lock:
if _drone_service is None:
_drone_service = DroneOpsService()
return _drone_service

View File

@@ -54,14 +54,6 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) -
# Alert failures should never break streaming # Alert failures should never break streaming
pass 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: def _extract_device_id(event: dict) -> str | None:
for field in DEVICE_ID_FIELDS: for field in DEVICE_ID_FIELDS:
value = event.get(field) value = event.get(field)