mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Remove Drone Ops feature end-to-end
This commit is contained in:
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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">▼</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">▼</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">▼</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">▼</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">▼</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>
|
|
||||||
@@ -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>') }}
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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'
|
|
||||||
@@ -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'
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
"""Authorization helpers for role-based and arming-gated operations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from flask import jsonify, session
|
|
||||||
|
|
||||||
ROLE_LEVELS: dict[str, int] = {
|
|
||||||
'viewer': 10,
|
|
||||||
'analyst': 20,
|
|
||||||
'operator': 30,
|
|
||||||
'supervisor': 40,
|
|
||||||
'admin': 50,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def current_username() -> str:
|
|
||||||
"""Get current username from session."""
|
|
||||||
return str(session.get('username') or 'anonymous')
|
|
||||||
|
|
||||||
|
|
||||||
def current_role() -> str:
|
|
||||||
"""Get current role from session with safe default."""
|
|
||||||
role = str(session.get('role') or 'viewer').strip().lower()
|
|
||||||
return role if role in ROLE_LEVELS else 'viewer'
|
|
||||||
|
|
||||||
|
|
||||||
def has_role(required_role: str) -> bool:
|
|
||||||
"""Return True if current session role satisfies required role."""
|
|
||||||
required = ROLE_LEVELS.get(required_role, ROLE_LEVELS['admin'])
|
|
||||||
actual = ROLE_LEVELS.get(current_role(), ROLE_LEVELS['viewer'])
|
|
||||||
return actual >= required
|
|
||||||
|
|
||||||
|
|
||||||
def require_role(required_role: str) -> Callable:
|
|
||||||
"""Decorator enforcing minimum role."""
|
|
||||||
|
|
||||||
def decorator(func: Callable) -> Callable:
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args: Any, **kwargs: Any):
|
|
||||||
if not has_role(required_role):
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'{required_role} role required',
|
|
||||||
'required_role': required_role,
|
|
||||||
'current_role': current_role(),
|
|
||||||
}), 403
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def require_armed(func: Callable) -> Callable:
|
|
||||||
"""Decorator enforcing armed state for active actions."""
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args: Any, **kwargs: Any):
|
|
||||||
from utils.drone import get_drone_ops_service
|
|
||||||
|
|
||||||
service = get_drone_ops_service()
|
|
||||||
policy = service.get_policy_state()
|
|
||||||
if not policy.get('armed'):
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Action plane is not armed',
|
|
||||||
'policy': policy,
|
|
||||||
}), 403
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
@@ -555,172 +555,6 @@ def init_db() -> None:
|
|||||||
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
|
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
"""Drone Ops utility package."""
|
|
||||||
|
|
||||||
from .service import DroneOpsService, get_drone_ops_service
|
|
||||||
from .remote_id import decode_remote_id_payload
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'DroneOpsService',
|
|
||||||
'get_drone_ops_service',
|
|
||||||
'decode_remote_id_payload',
|
|
||||||
]
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
"""Heuristics for identifying drone-related emissions across WiFi/BLE/RF feeds."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from utils.drone.remote_id import decode_remote_id_payload
|
|
||||||
|
|
||||||
SSID_PATTERNS = [
|
|
||||||
re.compile(r'(^|[-_\s])(dji|mavic|phantom|inspire|matrice|mini)([-_\s]|$)', re.IGNORECASE),
|
|
||||||
re.compile(r'(^|[-_\s])(parrot|anafi|bebop)([-_\s]|$)', re.IGNORECASE),
|
|
||||||
re.compile(r'(^|[-_\s])(autel|evo)([-_\s]|$)', re.IGNORECASE),
|
|
||||||
re.compile(r'(^|[-_\s])(skydio|yuneec)([-_\s]|$)', re.IGNORECASE),
|
|
||||||
re.compile(r'(^|[-_\s])(uas|uav|drone|rid|opendroneid)([-_\s]|$)', re.IGNORECASE),
|
|
||||||
]
|
|
||||||
|
|
||||||
DRONE_OUI_PREFIXES = {
|
|
||||||
'60:60:1F': 'DJI',
|
|
||||||
'90:3A:E6': 'DJI',
|
|
||||||
'34:D2:62': 'DJI',
|
|
||||||
'90:3A:AF': 'DJI',
|
|
||||||
'00:12:1C': 'Parrot',
|
|
||||||
'90:03:B7': 'Parrot',
|
|
||||||
'48:1C:B9': 'Autel',
|
|
||||||
'AC:89:95': 'Skydio',
|
|
||||||
}
|
|
||||||
|
|
||||||
BT_NAME_PATTERNS = [
|
|
||||||
re.compile(r'(dji|mavic|phantom|inspire|matrice|mini)', re.IGNORECASE),
|
|
||||||
re.compile(r'(parrot|anafi|bebop)', re.IGNORECASE),
|
|
||||||
re.compile(r'(autel|evo)', re.IGNORECASE),
|
|
||||||
re.compile(r'(skydio|yuneec)', re.IGNORECASE),
|
|
||||||
re.compile(r'(remote\s?id|opendroneid|uas|uav|drone)', re.IGNORECASE),
|
|
||||||
]
|
|
||||||
|
|
||||||
REMOTE_ID_UUID_HINTS = {'fffa', 'faff', 'fffb'}
|
|
||||||
RF_FREQ_HINTS_MHZ = (315.0, 433.92, 868.0, 915.0, 1200.0, 2400.0, 5800.0)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_mac(value: Any) -> str:
|
|
||||||
text = str(value or '').strip().upper().replace('-', ':')
|
|
||||||
if len(text) >= 8:
|
|
||||||
return text
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_wifi_event(event: dict) -> dict | None:
|
|
||||||
if not isinstance(event, dict):
|
|
||||||
return None
|
|
||||||
if isinstance(event.get('network'), dict):
|
|
||||||
return event['network']
|
|
||||||
if event.get('type') == 'network_update' and isinstance(event.get('network'), dict):
|
|
||||||
return event['network']
|
|
||||||
if any(k in event for k in ('bssid', 'essid', 'ssid')):
|
|
||||||
return event
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_bt_event(event: dict) -> dict | None:
|
|
||||||
if not isinstance(event, dict):
|
|
||||||
return None
|
|
||||||
if isinstance(event.get('device'), dict):
|
|
||||||
return event['device']
|
|
||||||
if any(k in event for k in ('device_id', 'address', 'name', 'manufacturer_name', 'service_uuids')):
|
|
||||||
return event
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_frequency_mhz(event: dict) -> float | None:
|
|
||||||
if not isinstance(event, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
event.get('frequency_mhz'),
|
|
||||||
event.get('frequency'),
|
|
||||||
]
|
|
||||||
|
|
||||||
if 'frequency_hz' in event:
|
|
||||||
try:
|
|
||||||
candidates.append(float(event['frequency_hz']) / 1_000_000.0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
for value in candidates:
|
|
||||||
try:
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
f = float(value)
|
|
||||||
if f > 100000: # likely in Hz
|
|
||||||
f = f / 1_000_000.0
|
|
||||||
if 1.0 <= f <= 7000.0:
|
|
||||||
return round(f, 6)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
text = str(event.get('text') or event.get('message') or '')
|
|
||||||
match = re.search(r'([0-9]{2,4}(?:\.[0-9]+)?)\s*MHz', text, flags=re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
try:
|
|
||||||
return float(match.group(1))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _closest_freq_delta(freq_mhz: float) -> float:
|
|
||||||
return min(abs(freq_mhz - hint) for hint in RF_FREQ_HINTS_MHZ)
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_track_from_remote_id(remote_id: dict, source: str) -> dict | None:
|
|
||||||
if not remote_id.get('detected'):
|
|
||||||
return None
|
|
||||||
lat = remote_id.get('lat')
|
|
||||||
lon = remote_id.get('lon')
|
|
||||||
if lat is None or lon is None:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
'lat': lat,
|
|
||||||
'lon': lon,
|
|
||||||
'altitude_m': remote_id.get('altitude_m'),
|
|
||||||
'speed_mps': remote_id.get('speed_mps'),
|
|
||||||
'heading_deg': remote_id.get('heading_deg'),
|
|
||||||
'quality': remote_id.get('confidence', 0.0),
|
|
||||||
'source': source,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_wifi(event: dict) -> list[dict]:
|
|
||||||
network = _extract_wifi_event(event)
|
|
||||||
if not network:
|
|
||||||
return []
|
|
||||||
|
|
||||||
bssid = _normalize_mac(network.get('bssid') or network.get('mac') or network.get('id'))
|
|
||||||
ssid = str(network.get('essid') or network.get('ssid') or network.get('display_name') or '').strip()
|
|
||||||
identifier = bssid or ssid
|
|
||||||
if not identifier:
|
|
||||||
return []
|
|
||||||
|
|
||||||
score = 0.0
|
|
||||||
reasons: list[str] = []
|
|
||||||
|
|
||||||
if ssid:
|
|
||||||
for pattern in SSID_PATTERNS:
|
|
||||||
if pattern.search(ssid):
|
|
||||||
score += 0.45
|
|
||||||
reasons.append('ssid_pattern')
|
|
||||||
break
|
|
||||||
|
|
||||||
if bssid and len(bssid) >= 8:
|
|
||||||
prefix = bssid[:8]
|
|
||||||
if prefix in DRONE_OUI_PREFIXES:
|
|
||||||
score += 0.45
|
|
||||||
reasons.append(f'known_oui:{DRONE_OUI_PREFIXES[prefix]}')
|
|
||||||
|
|
||||||
remote_id = decode_remote_id_payload(network)
|
|
||||||
if remote_id.get('detected'):
|
|
||||||
score = max(score, 0.75)
|
|
||||||
reasons.append('remote_id_payload')
|
|
||||||
|
|
||||||
if score < 0.5:
|
|
||||||
return []
|
|
||||||
|
|
||||||
confidence = min(1.0, round(score, 3))
|
|
||||||
classification = 'wifi_drone_remote_id' if remote_id.get('detected') else 'wifi_drone_signature'
|
|
||||||
|
|
||||||
return [{
|
|
||||||
'source': 'wifi',
|
|
||||||
'identifier': identifier,
|
|
||||||
'classification': classification,
|
|
||||||
'confidence': confidence,
|
|
||||||
'payload': {
|
|
||||||
'network': network,
|
|
||||||
'reasons': reasons,
|
|
||||||
'brand_hint': DRONE_OUI_PREFIXES.get(bssid[:8]) if bssid else None,
|
|
||||||
},
|
|
||||||
'remote_id': remote_id if remote_id.get('detected') else None,
|
|
||||||
'track': _maybe_track_from_remote_id(remote_id, 'wifi'),
|
|
||||||
}]
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_bluetooth(event: dict) -> list[dict]:
|
|
||||||
device = _extract_bt_event(event)
|
|
||||||
if not device:
|
|
||||||
return []
|
|
||||||
|
|
||||||
address = _normalize_mac(device.get('address') or device.get('mac'))
|
|
||||||
device_id = str(device.get('device_id') or '').strip()
|
|
||||||
name = str(device.get('name') or '').strip()
|
|
||||||
manufacturer = str(device.get('manufacturer_name') or '').strip()
|
|
||||||
identifier = address or device_id or name
|
|
||||||
if not identifier:
|
|
||||||
return []
|
|
||||||
|
|
||||||
score = 0.0
|
|
||||||
reasons: list[str] = []
|
|
||||||
|
|
||||||
haystack = f'{name} {manufacturer}'.strip()
|
|
||||||
if haystack:
|
|
||||||
for pattern in BT_NAME_PATTERNS:
|
|
||||||
if pattern.search(haystack):
|
|
||||||
score += 0.55
|
|
||||||
reasons.append('name_or_vendor_pattern')
|
|
||||||
break
|
|
||||||
|
|
||||||
uuids = device.get('service_uuids') or []
|
|
||||||
for uuid in uuids:
|
|
||||||
if str(uuid).replace('-', '').lower()[-4:] in REMOTE_ID_UUID_HINTS:
|
|
||||||
score = max(score, 0.7)
|
|
||||||
reasons.append('remote_id_service_uuid')
|
|
||||||
break
|
|
||||||
|
|
||||||
tracker = device.get('tracker') if isinstance(device.get('tracker'), dict) else {}
|
|
||||||
if tracker.get('is_tracker') and 'drone' in str(tracker.get('type') or '').lower():
|
|
||||||
score = max(score, 0.7)
|
|
||||||
reasons.append('tracker_engine_drone_label')
|
|
||||||
|
|
||||||
remote_id = decode_remote_id_payload(device)
|
|
||||||
if remote_id.get('detected'):
|
|
||||||
score = max(score, 0.75)
|
|
||||||
reasons.append('remote_id_payload')
|
|
||||||
|
|
||||||
if score < 0.55:
|
|
||||||
return []
|
|
||||||
|
|
||||||
confidence = min(1.0, round(score, 3))
|
|
||||||
classification = 'bluetooth_drone_remote_id' if remote_id.get('detected') else 'bluetooth_drone_signature'
|
|
||||||
|
|
||||||
return [{
|
|
||||||
'source': 'bluetooth',
|
|
||||||
'identifier': identifier,
|
|
||||||
'classification': classification,
|
|
||||||
'confidence': confidence,
|
|
||||||
'payload': {
|
|
||||||
'device': device,
|
|
||||||
'reasons': reasons,
|
|
||||||
},
|
|
||||||
'remote_id': remote_id if remote_id.get('detected') else None,
|
|
||||||
'track': _maybe_track_from_remote_id(remote_id, 'bluetooth'),
|
|
||||||
}]
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_rf(event: dict) -> list[dict]:
|
|
||||||
if not isinstance(event, dict):
|
|
||||||
return []
|
|
||||||
|
|
||||||
freq_mhz = _extract_frequency_mhz(event)
|
|
||||||
if freq_mhz is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
delta = _closest_freq_delta(freq_mhz)
|
|
||||||
if delta > 35.0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
score = max(0.5, 0.85 - (delta / 100.0))
|
|
||||||
confidence = min(1.0, round(score, 3))
|
|
||||||
|
|
||||||
event_id = str(event.get('capture_id') or event.get('id') or f'{freq_mhz:.3f}MHz')
|
|
||||||
identifier = f'rf:{event_id}'
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'event': event,
|
|
||||||
'frequency_mhz': freq_mhz,
|
|
||||||
'delta_from_known_band_mhz': round(delta, 3),
|
|
||||||
'known_bands_mhz': list(RF_FREQ_HINTS_MHZ),
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{
|
|
||||||
'source': 'rf',
|
|
||||||
'identifier': identifier,
|
|
||||||
'classification': 'rf_drone_link_activity',
|
|
||||||
'confidence': confidence,
|
|
||||||
'payload': payload,
|
|
||||||
'remote_id': None,
|
|
||||||
'track': None,
|
|
||||||
}]
|
|
||||||
|
|
||||||
|
|
||||||
def detect_from_event(mode: str, event: dict, event_type: str | None = None) -> list[dict]:
|
|
||||||
"""Detect drone-relevant signals from a normalized mode event."""
|
|
||||||
mode_lower = str(mode or '').lower()
|
|
||||||
|
|
||||||
if mode_lower.startswith('wifi'):
|
|
||||||
return _detect_wifi(event)
|
|
||||||
if mode_lower.startswith('bluetooth') or mode_lower.startswith('bt'):
|
|
||||||
return _detect_bluetooth(event)
|
|
||||||
if mode_lower in {'subghz', 'listening_scanner', 'waterfall', 'listening'}:
|
|
||||||
return _detect_rf(event)
|
|
||||||
|
|
||||||
# Opportunistic decode from any feed that carries explicit remote ID payloads.
|
|
||||||
remote_id = decode_remote_id_payload(event)
|
|
||||||
if remote_id.get('detected'):
|
|
||||||
identifier = str(remote_id.get('uas_id') or remote_id.get('operator_id') or 'remote_id')
|
|
||||||
return [{
|
|
||||||
'source': mode_lower or 'unknown',
|
|
||||||
'identifier': identifier,
|
|
||||||
'classification': 'remote_id_detected',
|
|
||||||
'confidence': float(remote_id.get('confidence') or 0.6),
|
|
||||||
'payload': {'event': event, 'event_type': event_type},
|
|
||||||
'remote_id': remote_id,
|
|
||||||
'track': _maybe_track_from_remote_id(remote_id, mode_lower or 'unknown'),
|
|
||||||
}]
|
|
||||||
|
|
||||||
return []
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""Drone Ops policy helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def required_approvals_for_action(action_type: str) -> int:
|
|
||||||
"""Return required approvals for a given action type."""
|
|
||||||
action = (action_type or '').strip().lower()
|
|
||||||
if action.startswith('passive_'):
|
|
||||||
return 1
|
|
||||||
return 2
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"""Remote ID payload normalization and lightweight decoding helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
DRONE_ID_KEYS = ('uas_id', 'drone_id', 'serial_number', 'serial', 'id', 'uasId')
|
|
||||||
OPERATOR_ID_KEYS = ('operator_id', 'pilot_id', 'operator', 'operatorId')
|
|
||||||
LAT_KEYS = ('lat', 'latitude')
|
|
||||||
LON_KEYS = ('lon', 'lng', 'longitude')
|
|
||||||
ALT_KEYS = ('alt', 'altitude', 'altitude_m', 'height')
|
|
||||||
SPEED_KEYS = ('speed', 'speed_mps', 'ground_speed')
|
|
||||||
HEADING_KEYS = ('heading', 'heading_deg', 'course')
|
|
||||||
|
|
||||||
|
|
||||||
def _get_nested(data: dict, *paths: str) -> Any:
|
|
||||||
for path in paths:
|
|
||||||
current: Any = data
|
|
||||||
valid = True
|
|
||||||
for part in path.split('.'):
|
|
||||||
if not isinstance(current, dict) or part not in current:
|
|
||||||
valid = False
|
|
||||||
break
|
|
||||||
current = current[part]
|
|
||||||
if valid:
|
|
||||||
return current
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_float(value: Any) -> float | None:
|
|
||||||
try:
|
|
||||||
if value is None or value == '':
|
|
||||||
return None
|
|
||||||
return float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _pick(data: dict, keys: tuple[str, ...], nested_prefixes: tuple[str, ...] = ()) -> Any:
|
|
||||||
for key in keys:
|
|
||||||
if key in data:
|
|
||||||
return data.get(key)
|
|
||||||
for prefix in nested_prefixes:
|
|
||||||
for key in keys:
|
|
||||||
hit = _get_nested(data, f'{prefix}.{key}')
|
|
||||||
if hit is not None:
|
|
||||||
return hit
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_input(payload: Any) -> tuple[dict, str]:
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
return payload, 'dict'
|
|
||||||
|
|
||||||
if isinstance(payload, bytes):
|
|
||||||
text = payload.decode('utf-8', errors='replace').strip()
|
|
||||||
else:
|
|
||||||
text = str(payload or '').strip()
|
|
||||||
|
|
||||||
if not text:
|
|
||||||
return {}, 'empty'
|
|
||||||
|
|
||||||
# JSON-first parsing.
|
|
||||||
try:
|
|
||||||
parsed = json.loads(text)
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
return parsed, 'json'
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Keep opaque string payload available to caller.
|
|
||||||
return {'raw': text}, 'raw'
|
|
||||||
|
|
||||||
|
|
||||||
def decode_remote_id_payload(payload: Any) -> dict:
|
|
||||||
"""Decode/normalize Remote ID-like payload into a common shape."""
|
|
||||||
data, fmt = _normalize_input(payload)
|
|
||||||
|
|
||||||
drone_id = _pick(data, DRONE_ID_KEYS, ('remote_id', 'message', 'uas'))
|
|
||||||
operator_id = _pick(data, OPERATOR_ID_KEYS, ('remote_id', 'message', 'operator'))
|
|
||||||
|
|
||||||
lat = _coerce_float(_pick(data, LAT_KEYS, ('remote_id', 'message', 'position')))
|
|
||||||
lon = _coerce_float(_pick(data, LON_KEYS, ('remote_id', 'message', 'position')))
|
|
||||||
altitude_m = _coerce_float(_pick(data, ALT_KEYS, ('remote_id', 'message', 'position')))
|
|
||||||
speed_mps = _coerce_float(_pick(data, SPEED_KEYS, ('remote_id', 'message', 'position')))
|
|
||||||
heading_deg = _coerce_float(_pick(data, HEADING_KEYS, ('remote_id', 'message', 'position')))
|
|
||||||
|
|
||||||
confidence = 0.0
|
|
||||||
if drone_id:
|
|
||||||
confidence += 0.35
|
|
||||||
if lat is not None and lon is not None:
|
|
||||||
confidence += 0.35
|
|
||||||
if altitude_m is not None:
|
|
||||||
confidence += 0.15
|
|
||||||
if operator_id:
|
|
||||||
confidence += 0.15
|
|
||||||
confidence = min(1.0, round(confidence, 3))
|
|
||||||
|
|
||||||
detected = bool(drone_id or (lat is not None and lon is not None and confidence >= 0.35))
|
|
||||||
|
|
||||||
normalized = {
|
|
||||||
'detected': detected,
|
|
||||||
'source_format': fmt,
|
|
||||||
'uas_id': str(drone_id).strip() if drone_id else None,
|
|
||||||
'operator_id': str(operator_id).strip() if operator_id else None,
|
|
||||||
'lat': lat,
|
|
||||||
'lon': lon,
|
|
||||||
'altitude_m': altitude_m,
|
|
||||||
'speed_mps': speed_mps,
|
|
||||||
'heading_deg': heading_deg,
|
|
||||||
'confidence': confidence,
|
|
||||||
'raw': data,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove heavy raw payload if we successfully extracted structure.
|
|
||||||
if detected and isinstance(data, dict) and len(data) > 0:
|
|
||||||
normalized['raw'] = data
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
@@ -1,621 +0,0 @@
|
|||||||
"""Stateful Drone Ops service: ingestion, policy, incidents, actions, and evidence."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import queue
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Generator
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
|
|
||||||
from utils.correlation import get_correlations as get_wifi_bt_correlations
|
|
||||||
from utils.database import (
|
|
||||||
add_action_approval,
|
|
||||||
add_action_audit_log,
|
|
||||||
add_drone_correlation,
|
|
||||||
add_drone_incident_artifact,
|
|
||||||
add_drone_track,
|
|
||||||
create_action_request,
|
|
||||||
create_drone_incident,
|
|
||||||
create_drone_session,
|
|
||||||
create_evidence_manifest,
|
|
||||||
get_action_request,
|
|
||||||
get_active_drone_session,
|
|
||||||
get_drone_detection,
|
|
||||||
get_drone_incident,
|
|
||||||
get_drone_session,
|
|
||||||
get_evidence_manifest,
|
|
||||||
list_action_audit_logs,
|
|
||||||
list_action_requests,
|
|
||||||
list_drone_correlations,
|
|
||||||
list_drone_detections,
|
|
||||||
list_drone_incidents,
|
|
||||||
list_drone_sessions,
|
|
||||||
list_drone_tracks,
|
|
||||||
list_evidence_manifests,
|
|
||||||
stop_drone_session,
|
|
||||||
update_action_request_status,
|
|
||||||
update_drone_incident,
|
|
||||||
upsert_drone_detection,
|
|
||||||
)
|
|
||||||
from utils.drone.detector import detect_from_event
|
|
||||||
from utils.drone.remote_id import decode_remote_id_payload
|
|
||||||
from utils.trilateration import estimate_location_from_observations
|
|
||||||
|
|
||||||
|
|
||||||
class DroneOpsService:
|
|
||||||
"""Orchestrates Drone Ops data and policy controls."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._subscribers: set[queue.Queue] = set()
|
|
||||||
self._subs_lock = threading.Lock()
|
|
||||||
|
|
||||||
self._policy_lock = threading.Lock()
|
|
||||||
self._armed_until_ts: float | None = None
|
|
||||||
self._armed_by: str | None = None
|
|
||||||
self._arm_reason: str | None = None
|
|
||||||
self._arm_incident_id: int | None = None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Streaming
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _utc_now_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
def _emit(self, event_type: str, payload: dict) -> None:
|
|
||||||
envelope = {
|
|
||||||
'type': event_type,
|
|
||||||
'timestamp': self._utc_now_iso(),
|
|
||||||
'payload': payload,
|
|
||||||
}
|
|
||||||
with self._subs_lock:
|
|
||||||
subscribers = tuple(self._subscribers)
|
|
||||||
|
|
||||||
for sub in subscribers:
|
|
||||||
try:
|
|
||||||
sub.put_nowait(envelope)
|
|
||||||
except queue.Full:
|
|
||||||
try:
|
|
||||||
sub.get_nowait()
|
|
||||||
sub.put_nowait(envelope)
|
|
||||||
except (queue.Empty, queue.Full):
|
|
||||||
continue
|
|
||||||
|
|
||||||
def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]:
|
|
||||||
"""Yield Drone Ops events for SSE streaming."""
|
|
||||||
client_queue: queue.Queue = queue.Queue(maxsize=500)
|
|
||||||
|
|
||||||
with self._subs_lock:
|
|
||||||
self._subscribers.add(client_queue)
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
yield client_queue.get(timeout=timeout)
|
|
||||||
except queue.Empty:
|
|
||||||
yield {'type': 'keepalive', 'timestamp': self._utc_now_iso(), 'payload': {}}
|
|
||||||
finally:
|
|
||||||
with self._subs_lock:
|
|
||||||
self._subscribers.discard(client_queue)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Policy / arming
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _policy_state_locked(self) -> dict:
|
|
||||||
armed = self._armed_until_ts is not None and time.time() < self._armed_until_ts
|
|
||||||
if not armed:
|
|
||||||
self._armed_until_ts = None
|
|
||||||
self._armed_by = None
|
|
||||||
self._arm_reason = None
|
|
||||||
self._arm_incident_id = None
|
|
||||||
|
|
||||||
return {
|
|
||||||
'armed': armed,
|
|
||||||
'armed_by': self._armed_by,
|
|
||||||
'arm_reason': self._arm_reason,
|
|
||||||
'arm_incident_id': self._arm_incident_id,
|
|
||||||
'armed_until': datetime.fromtimestamp(self._armed_until_ts, tz=timezone.utc).isoformat() if self._armed_until_ts else None,
|
|
||||||
'required_approvals_default': 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_policy_state(self) -> dict:
|
|
||||||
"""Get current policy and arming state."""
|
|
||||||
with self._policy_lock:
|
|
||||||
return self._policy_state_locked()
|
|
||||||
|
|
||||||
def arm_actions(
|
|
||||||
self,
|
|
||||||
actor: str,
|
|
||||||
reason: str,
|
|
||||||
incident_id: int,
|
|
||||||
duration_seconds: int = 900,
|
|
||||||
) -> dict:
|
|
||||||
"""Arm action plane for a bounded duration."""
|
|
||||||
duration_seconds = max(60, min(7200, int(duration_seconds or 900)))
|
|
||||||
with self._policy_lock:
|
|
||||||
self._armed_until_ts = time.time() + duration_seconds
|
|
||||||
self._armed_by = actor
|
|
||||||
self._arm_reason = reason
|
|
||||||
self._arm_incident_id = incident_id
|
|
||||||
state = self._policy_state_locked()
|
|
||||||
|
|
||||||
self._emit('policy_armed', {'actor': actor, 'reason': reason, 'incident_id': incident_id, 'state': state})
|
|
||||||
return state
|
|
||||||
|
|
||||||
def disarm_actions(self, actor: str, reason: str | None = None) -> dict:
|
|
||||||
"""Disarm action plane."""
|
|
||||||
with self._policy_lock:
|
|
||||||
self._armed_until_ts = None
|
|
||||||
self._armed_by = None
|
|
||||||
self._arm_reason = None
|
|
||||||
self._arm_incident_id = None
|
|
||||||
state = self._policy_state_locked()
|
|
||||||
|
|
||||||
self._emit('policy_disarmed', {'actor': actor, 'reason': reason, 'state': state})
|
|
||||||
return state
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def required_approvals(action_type: str) -> int:
|
|
||||||
"""Compute required approvals for an action type."""
|
|
||||||
action = (action_type or '').strip().lower()
|
|
||||||
if action.startswith('passive_'):
|
|
||||||
return 1
|
|
||||||
return 2
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Sessions and detections
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def start_session(
|
|
||||||
self,
|
|
||||||
mode: str,
|
|
||||||
label: str | None,
|
|
||||||
operator: str,
|
|
||||||
metadata: dict | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Start a Drone Ops session."""
|
|
||||||
active = get_active_drone_session()
|
|
||||||
if active:
|
|
||||||
return active
|
|
||||||
|
|
||||||
session_id = create_drone_session(
|
|
||||||
mode=mode or 'passive',
|
|
||||||
label=label,
|
|
||||||
operator=operator,
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
session = get_drone_session(session_id)
|
|
||||||
if session:
|
|
||||||
self._emit('session_started', {'session': session})
|
|
||||||
return session or {}
|
|
||||||
|
|
||||||
def stop_session(
|
|
||||||
self,
|
|
||||||
operator: str,
|
|
||||||
session_id: int | None = None,
|
|
||||||
summary: dict | None = None,
|
|
||||||
) -> dict | None:
|
|
||||||
"""Stop a Drone Ops session."""
|
|
||||||
active = get_active_drone_session()
|
|
||||||
target_id = session_id or (active['id'] if active else None)
|
|
||||||
if not target_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if summary is None:
|
|
||||||
summary = {
|
|
||||||
'operator': operator,
|
|
||||||
'stopped_at': self._utc_now_iso(),
|
|
||||||
'detections': len(list_drone_detections(session_id=target_id, limit=1000)),
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_drone_session(target_id, summary=summary)
|
|
||||||
session = get_drone_session(target_id)
|
|
||||||
if session:
|
|
||||||
self._emit('session_stopped', {'session': session})
|
|
||||||
return session
|
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
|
||||||
"""Get full Drone Ops status payload."""
|
|
||||||
return {
|
|
||||||
'status': 'success',
|
|
||||||
'active_session': get_active_drone_session(),
|
|
||||||
'policy': self.get_policy_state(),
|
|
||||||
'counts': {
|
|
||||||
'detections': len(list_drone_detections(limit=1000)),
|
|
||||||
'incidents_open': len(list_drone_incidents(status='open', limit=1000)),
|
|
||||||
'actions_pending': len(list_action_requests(status='pending', limit=1000)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def ingest_event(self, mode: str, event: dict, event_type: str | None = None) -> None:
|
|
||||||
"""Ingest cross-mode event and produce Drone Ops detections."""
|
|
||||||
try:
|
|
||||||
detections = detect_from_event(mode, event, event_type)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not detections:
|
|
||||||
return
|
|
||||||
|
|
||||||
active = get_active_drone_session()
|
|
||||||
session_id = active['id'] if active else None
|
|
||||||
|
|
||||||
for detection in detections:
|
|
||||||
try:
|
|
||||||
detection_id = upsert_drone_detection(
|
|
||||||
session_id=session_id,
|
|
||||||
source=detection['source'],
|
|
||||||
identifier=detection['identifier'],
|
|
||||||
classification=detection.get('classification'),
|
|
||||||
confidence=float(detection.get('confidence') or 0.0),
|
|
||||||
payload=detection.get('payload') or {},
|
|
||||||
remote_id=detection.get('remote_id') or None,
|
|
||||||
)
|
|
||||||
row = get_drone_detection(detection_id)
|
|
||||||
|
|
||||||
track = detection.get('track') or {}
|
|
||||||
if row and track and track.get('lat') is not None and track.get('lon') is not None:
|
|
||||||
add_drone_track(
|
|
||||||
detection_id=row['id'],
|
|
||||||
lat=track.get('lat'),
|
|
||||||
lon=track.get('lon'),
|
|
||||||
altitude_m=track.get('altitude_m'),
|
|
||||||
speed_mps=track.get('speed_mps'),
|
|
||||||
heading_deg=track.get('heading_deg'),
|
|
||||||
quality=track.get('quality'),
|
|
||||||
source=track.get('source') or detection.get('source'),
|
|
||||||
)
|
|
||||||
|
|
||||||
remote_id = detection.get('remote_id') or {}
|
|
||||||
uas_id = remote_id.get('uas_id')
|
|
||||||
operator_id = remote_id.get('operator_id')
|
|
||||||
if uas_id and operator_id:
|
|
||||||
add_drone_correlation(
|
|
||||||
drone_identifier=str(uas_id),
|
|
||||||
operator_identifier=str(operator_id),
|
|
||||||
method='remote_id_binding',
|
|
||||||
confidence=float(remote_id.get('confidence') or 0.8),
|
|
||||||
evidence={
|
|
||||||
'source': detection.get('source'),
|
|
||||||
'event_type': event_type,
|
|
||||||
'detection_id': row['id'] if row else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if row:
|
|
||||||
self._emit('detection', {
|
|
||||||
'mode': mode,
|
|
||||||
'event_type': event_type,
|
|
||||||
'detection': row,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
def decode_remote_id(self, payload: Any) -> dict:
|
|
||||||
"""Decode an explicit Remote ID payload."""
|
|
||||||
decoded = decode_remote_id_payload(payload)
|
|
||||||
self._emit('remote_id_decoded', {'decoded': decoded})
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Queries
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_detections(
|
|
||||||
self,
|
|
||||||
session_id: int | None = None,
|
|
||||||
source: str | None = None,
|
|
||||||
min_confidence: float = 0.0,
|
|
||||||
limit: int = 200,
|
|
||||||
) -> list[dict]:
|
|
||||||
return list_drone_detections(
|
|
||||||
session_id=session_id,
|
|
||||||
source=source,
|
|
||||||
min_confidence=min_confidence,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_tracks(
|
|
||||||
self,
|
|
||||||
detection_id: int | None = None,
|
|
||||||
identifier: str | None = None,
|
|
||||||
limit: int = 1000,
|
|
||||||
) -> list[dict]:
|
|
||||||
return list_drone_tracks(
|
|
||||||
detection_id=detection_id,
|
|
||||||
identifier=identifier,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
def estimate_geolocation(self, observations: list[dict], environment: str = 'outdoor') -> dict | None:
|
|
||||||
"""Estimate location from observations."""
|
|
||||||
return estimate_location_from_observations(observations, environment=environment)
|
|
||||||
|
|
||||||
def refresh_correlations(self, min_confidence: float = 0.6) -> list[dict]:
|
|
||||||
"""Refresh and persist likely drone/operator correlations from WiFi<->BT pairs."""
|
|
||||||
wifi_devices = dict(app_module.wifi_networks)
|
|
||||||
wifi_devices.update(dict(app_module.wifi_clients))
|
|
||||||
bt_devices = dict(app_module.bt_devices)
|
|
||||||
|
|
||||||
pairs = get_wifi_bt_correlations(
|
|
||||||
wifi_devices=wifi_devices,
|
|
||||||
bt_devices=bt_devices,
|
|
||||||
min_confidence=min_confidence,
|
|
||||||
include_historical=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
detections = list_drone_detections(min_confidence=0.5, limit=1000)
|
|
||||||
known_ids = {d['identifier'].upper() for d in detections}
|
|
||||||
|
|
||||||
for pair in pairs:
|
|
||||||
wifi_mac = str(pair.get('wifi_mac') or '').upper()
|
|
||||||
bt_mac = str(pair.get('bt_mac') or '').upper()
|
|
||||||
if wifi_mac in known_ids or bt_mac in known_ids:
|
|
||||||
add_drone_correlation(
|
|
||||||
drone_identifier=wifi_mac if wifi_mac in known_ids else bt_mac,
|
|
||||||
operator_identifier=bt_mac if wifi_mac in known_ids else wifi_mac,
|
|
||||||
method='wifi_bt_correlation',
|
|
||||||
confidence=float(pair.get('confidence') or 0.0),
|
|
||||||
evidence=pair,
|
|
||||||
)
|
|
||||||
|
|
||||||
return list_drone_correlations(min_confidence=min_confidence, limit=200)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Incidents and artifacts
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def create_incident(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
severity: str,
|
|
||||||
opened_by: str,
|
|
||||||
summary: str | None,
|
|
||||||
metadata: dict | None,
|
|
||||||
) -> dict:
|
|
||||||
incident_id = create_drone_incident(
|
|
||||||
title=title,
|
|
||||||
severity=severity,
|
|
||||||
opened_by=opened_by,
|
|
||||||
summary=summary,
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
incident = get_drone_incident(incident_id) or {'id': incident_id}
|
|
||||||
self._emit('incident_created', {'incident': incident})
|
|
||||||
return incident
|
|
||||||
|
|
||||||
def update_incident(
|
|
||||||
self,
|
|
||||||
incident_id: int,
|
|
||||||
status: str | None = None,
|
|
||||||
severity: str | None = None,
|
|
||||||
summary: str | None = None,
|
|
||||||
metadata: dict | None = None,
|
|
||||||
) -> dict | None:
|
|
||||||
update_drone_incident(
|
|
||||||
incident_id=incident_id,
|
|
||||||
status=status,
|
|
||||||
severity=severity,
|
|
||||||
summary=summary,
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
incident = get_drone_incident(incident_id)
|
|
||||||
if incident:
|
|
||||||
self._emit('incident_updated', {'incident': incident})
|
|
||||||
return incident
|
|
||||||
|
|
||||||
def add_incident_artifact(
|
|
||||||
self,
|
|
||||||
incident_id: int,
|
|
||||||
artifact_type: str,
|
|
||||||
artifact_ref: str,
|
|
||||||
added_by: str,
|
|
||||||
metadata: dict | None = None,
|
|
||||||
) -> dict:
|
|
||||||
artifact_id = add_drone_incident_artifact(
|
|
||||||
incident_id=incident_id,
|
|
||||||
artifact_type=artifact_type,
|
|
||||||
artifact_ref=artifact_ref,
|
|
||||||
added_by=added_by,
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
artifact = {
|
|
||||||
'id': artifact_id,
|
|
||||||
'incident_id': incident_id,
|
|
||||||
'artifact_type': artifact_type,
|
|
||||||
'artifact_ref': artifact_ref,
|
|
||||||
'added_by': added_by,
|
|
||||||
'metadata': metadata or {},
|
|
||||||
}
|
|
||||||
self._emit('incident_artifact_added', {'artifact': artifact})
|
|
||||||
return artifact
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Actions and approvals
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def request_action(
|
|
||||||
self,
|
|
||||||
incident_id: int,
|
|
||||||
action_type: str,
|
|
||||||
requested_by: str,
|
|
||||||
payload: dict | None,
|
|
||||||
) -> dict | None:
|
|
||||||
request_id = create_action_request(
|
|
||||||
incident_id=incident_id,
|
|
||||||
action_type=action_type,
|
|
||||||
requested_by=requested_by,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
add_action_audit_log(
|
|
||||||
request_id=request_id,
|
|
||||||
event_type='requested',
|
|
||||||
actor=requested_by,
|
|
||||||
details={'payload': payload or {}},
|
|
||||||
)
|
|
||||||
req = get_action_request(request_id)
|
|
||||||
if req:
|
|
||||||
req['required_approvals'] = self.required_approvals(req['action_type'])
|
|
||||||
self._emit('action_requested', {'request': req})
|
|
||||||
return req
|
|
||||||
|
|
||||||
def approve_action(
|
|
||||||
self,
|
|
||||||
request_id: int,
|
|
||||||
approver: str,
|
|
||||||
decision: str = 'approved',
|
|
||||||
notes: str | None = None,
|
|
||||||
) -> dict | None:
|
|
||||||
req = get_action_request(request_id)
|
|
||||||
if not req:
|
|
||||||
return None
|
|
||||||
|
|
||||||
approvals = req.get('approvals', [])
|
|
||||||
if any((a.get('approved_by') or '').lower() == approver.lower() for a in approvals):
|
|
||||||
return req
|
|
||||||
|
|
||||||
add_action_approval(request_id=request_id, approved_by=approver, decision=decision, notes=notes)
|
|
||||||
add_action_audit_log(
|
|
||||||
request_id=request_id,
|
|
||||||
event_type='approval',
|
|
||||||
actor=approver,
|
|
||||||
details={'decision': decision, 'notes': notes},
|
|
||||||
)
|
|
||||||
|
|
||||||
req = get_action_request(request_id)
|
|
||||||
if not req:
|
|
||||||
return None
|
|
||||||
|
|
||||||
approvals = req.get('approvals', [])
|
|
||||||
approved_count = len([a for a in approvals if str(a.get('decision')).lower() == 'approved'])
|
|
||||||
required = self.required_approvals(req['action_type'])
|
|
||||||
|
|
||||||
if decision.lower() == 'rejected':
|
|
||||||
update_action_request_status(request_id, status='rejected')
|
|
||||||
elif approved_count >= required and req.get('status') not in {'executed', 'rejected'}:
|
|
||||||
update_action_request_status(request_id, status='approved')
|
|
||||||
|
|
||||||
req = get_action_request(request_id)
|
|
||||||
if req:
|
|
||||||
req['required_approvals'] = required
|
|
||||||
req['approved_count'] = approved_count
|
|
||||||
self._emit('action_approved', {'request': req})
|
|
||||||
return req
|
|
||||||
|
|
||||||
def execute_action(self, request_id: int, actor: str) -> tuple[dict | None, str | None]:
|
|
||||||
"""Execute an approved action request (policy-gated)."""
|
|
||||||
req = get_action_request(request_id)
|
|
||||||
if not req:
|
|
||||||
return None, 'Action request not found'
|
|
||||||
|
|
||||||
policy = self.get_policy_state()
|
|
||||||
if not policy.get('armed'):
|
|
||||||
return None, 'Action plane is not armed'
|
|
||||||
|
|
||||||
approvals = req.get('approvals', [])
|
|
||||||
approved_count = len([a for a in approvals if str(a.get('decision')).lower() == 'approved'])
|
|
||||||
required = self.required_approvals(req['action_type'])
|
|
||||||
|
|
||||||
if approved_count < required:
|
|
||||||
return None, f'Insufficient approvals ({approved_count}/{required})'
|
|
||||||
|
|
||||||
update_action_request_status(request_id, status='executed', executed_by=actor)
|
|
||||||
add_action_audit_log(
|
|
||||||
request_id=request_id,
|
|
||||||
event_type='executed',
|
|
||||||
actor=actor,
|
|
||||||
details={
|
|
||||||
'dispatch': 'framework',
|
|
||||||
'note': 'Execution recorded. Attach route-specific handlers per action_type.',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
req = get_action_request(request_id)
|
|
||||||
if req:
|
|
||||||
req['required_approvals'] = required
|
|
||||||
req['approved_count'] = approved_count
|
|
||||||
self._emit('action_executed', {'request': req})
|
|
||||||
return req, None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Evidence and manifests
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def generate_evidence_manifest(
|
|
||||||
self,
|
|
||||||
incident_id: int,
|
|
||||||
created_by: str,
|
|
||||||
signature: str | None = None,
|
|
||||||
) -> dict | None:
|
|
||||||
"""Build and persist an evidence manifest for an incident."""
|
|
||||||
incident = get_drone_incident(incident_id)
|
|
||||||
if not incident:
|
|
||||||
return None
|
|
||||||
|
|
||||||
action_requests = list_action_requests(incident_id=incident_id, limit=1000)
|
|
||||||
request_ids = [r['id'] for r in action_requests]
|
|
||||||
action_audit: list[dict] = []
|
|
||||||
for request_id in request_ids:
|
|
||||||
action_audit.extend(list_action_audit_logs(request_id=request_id, limit=500))
|
|
||||||
|
|
||||||
manifest_core = {
|
|
||||||
'generated_at': self._utc_now_iso(),
|
|
||||||
'incident': {
|
|
||||||
'id': incident['id'],
|
|
||||||
'title': incident['title'],
|
|
||||||
'status': incident['status'],
|
|
||||||
'severity': incident['severity'],
|
|
||||||
'opened_at': incident['opened_at'],
|
|
||||||
'closed_at': incident['closed_at'],
|
|
||||||
},
|
|
||||||
'artifact_count': len(incident.get('artifacts', [])),
|
|
||||||
'action_request_count': len(action_requests),
|
|
||||||
'audit_event_count': len(action_audit),
|
|
||||||
'artifacts': incident.get('artifacts', []),
|
|
||||||
'action_requests': action_requests,
|
|
||||||
'action_audit': action_audit,
|
|
||||||
}
|
|
||||||
|
|
||||||
canonical = json.dumps(manifest_core, sort_keys=True, separators=(',', ':'))
|
|
||||||
sha256_hex = hashlib.sha256(canonical.encode('utf-8')).hexdigest()
|
|
||||||
|
|
||||||
manifest = {
|
|
||||||
**manifest_core,
|
|
||||||
'integrity': {
|
|
||||||
'algorithm': 'sha256',
|
|
||||||
'digest': sha256_hex,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest_id = create_evidence_manifest(
|
|
||||||
incident_id=incident_id,
|
|
||||||
manifest=manifest,
|
|
||||||
hash_algo='sha256',
|
|
||||||
signature=signature,
|
|
||||||
created_by=created_by,
|
|
||||||
)
|
|
||||||
|
|
||||||
stored = get_evidence_manifest(manifest_id)
|
|
||||||
if stored:
|
|
||||||
self._emit('evidence_manifest_created', {'manifest': stored})
|
|
||||||
return stored
|
|
||||||
|
|
||||||
|
|
||||||
_drone_service: DroneOpsService | None = None
|
|
||||||
_drone_service_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_drone_ops_service() -> DroneOpsService:
|
|
||||||
"""Get global Drone Ops service singleton."""
|
|
||||||
global _drone_service
|
|
||||||
with _drone_service_lock:
|
|
||||||
if _drone_service is None:
|
|
||||||
_drone_service = DroneOpsService()
|
|
||||||
return _drone_service
|
|
||||||
@@ -54,14 +54,6 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) -
|
|||||||
# Alert failures should never break streaming
|
# 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user