Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db304631f8 | |||
| eae1820fda | |||
| f70deb32a2 | |||
| 69eea1e895 | |||
| bf4346b4ff | |||
| 7cde6a2068 | |||
| 84b424b02e | |||
| 04b73596ea | |||
| 3916276de8 | |||
| 077d46f319 | |||
| a0fd6d9651 | |||
| 8d505eb848 | |||
| 3f364f47e9 | |||
| b92139f207 | |||
| c7e9a0a493 | |||
| 717dec4e54 | |||
| d3cb20cdae | |||
| 518da075de | |||
| fb31157fe9 | |||
| a5f574062d | |||
| afccb6fe0a | |||
| f916b9fa19 | |||
| d775ba5b3e | |||
| 3372daca84 | |||
| b72ddd7c19 | |||
| f980e2e76d | |||
| ada6d5f1f1 | |||
| 7c6416ac38 | |||
| e833488425 | |||
| 0b8863aaa9 | |||
| 8d30c40fe2 |
@@ -10,17 +10,17 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
uv.lock
|
uv.lock
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
pager_messages.log
|
pager_messages.log
|
||||||
|
|
||||||
# Local data
|
# Local data
|
||||||
downloads/
|
downloads/
|
||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
# Local data
|
# Local data
|
||||||
downloads/
|
downloads/
|
||||||
pgdata/
|
pgdata/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
@@ -42,4 +42,15 @@ build/
|
|||||||
uv.lock
|
uv.lock
|
||||||
*.db
|
*.db
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
intercept.db
|
intercept.db
|
||||||
|
|
||||||
|
# Instance folder (contains database with user data)
|
||||||
|
instance/
|
||||||
|
|
||||||
|
# Agent configs with real credentials (keep template only)
|
||||||
|
intercept_agent_*.cfg
|
||||||
|
!intercept_agent.cfg
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
/tmp/
|
||||||
|
*.tmp
|
||||||
|
|||||||
@@ -2,6 +2,56 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.11.0] - 2026-01-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Meshtastic Mesh Network Integration** - LoRa mesh communication support
|
||||||
|
- Connect to Meshtastic devices (Heltec, T-Beam, RAK) via USB/Serial
|
||||||
|
- Real-time message streaming via SSE
|
||||||
|
- Channel configuration with encryption key support
|
||||||
|
- Node information display with signal metrics (RSSI, SNR)
|
||||||
|
- Message history with up to 500 messages
|
||||||
|
- **Ubertooth One BLE Scanner** - Advanced Bluetooth scanning
|
||||||
|
- Passive BLE packet capture across all 40 BLE channels
|
||||||
|
- Raw advertising payload access
|
||||||
|
- Integration with existing Bluetooth scanning modes
|
||||||
|
- Automatic detection of Ubertooth hardware
|
||||||
|
- **Offline Mode** - Run iNTERCEPT without internet connectivity
|
||||||
|
- Bundled Leaflet 1.9.4 (JS, CSS, marker images)
|
||||||
|
- Bundled Chart.js 4.4.1
|
||||||
|
- Bundled Inter and JetBrains Mono fonts (woff2)
|
||||||
|
- Local asset status checking and validation
|
||||||
|
- **Settings Modal** - New configuration interface accessible from navigation
|
||||||
|
- Offline tab: Toggle offline mode, configure asset sources
|
||||||
|
- Display tab: Theme and animation preferences
|
||||||
|
- About tab: Version info and links
|
||||||
|
- **Multiple Map Tile Providers** - Choose from:
|
||||||
|
- OpenStreetMap (default)
|
||||||
|
- CartoDB Dark
|
||||||
|
- CartoDB Positron (light)
|
||||||
|
- ESRI World Imagery
|
||||||
|
- Custom tile server URL
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dashboard Templates** - Conditional asset loading based on offline settings
|
||||||
|
- **Bluetooth Scanner** - Added Ubertooth backend alongside BlueZ/DBus
|
||||||
|
- **Dependencies** - Added meshtastic SDK to requirements.txt
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Added `routes/meshtastic.py` for Meshtastic API endpoints
|
||||||
|
- Added `utils/meshtastic.py` for device management
|
||||||
|
- Added `utils/bluetooth/ubertooth_scanner.py` for Ubertooth support
|
||||||
|
- Added `routes/offline.py` for offline mode API
|
||||||
|
- Added `static/js/core/settings-manager.js` for client-side settings
|
||||||
|
- Added `static/css/settings.css` for settings modal styles
|
||||||
|
- Added `static/css/modes/meshtastic.css` for Meshtastic UI
|
||||||
|
- Added `static/js/modes/meshtastic.js` for Meshtastic frontend
|
||||||
|
- Added `templates/partials/modes/meshtastic.html` for Meshtastic mode
|
||||||
|
- Added `templates/partials/settings-modal.html` for settings UI
|
||||||
|
- Added `static/vendor/` directory structure for bundled assets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.10.0] - 2026-01-25
|
## [2.10.0] - 2026-01-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -35,8 +35,11 @@ Support the developer of this open-source project
|
|||||||
- **Satellite Tracking** - Pass prediction using TLE data
|
- **Satellite Tracking** - Pass prediction using TLE data
|
||||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
|
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||||
|
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,6 +117,7 @@ Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
||||||
|
- [Distributed Agents](docs/DISTRIBUTED_AGENTS.md) - Remote sensor node deployment
|
||||||
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
||||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||||
- [Security](docs/SECURITY.md) - Network security and best practices
|
- [Security](docs/SECURITY.md) - Network security and best practices
|
||||||
|
|||||||
@@ -91,6 +91,25 @@ def add_security_headers(response):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# CONTEXT PROCESSORS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_offline_settings():
|
||||||
|
"""Inject offline settings into all templates."""
|
||||||
|
from utils.database import get_setting
|
||||||
|
return {
|
||||||
|
'offline_settings': {
|
||||||
|
'enabled': get_setting('offline.enabled', False),
|
||||||
|
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||||
|
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||||
|
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
|
||||||
|
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# GLOBAL PROCESS MANAGEMENT
|
# GLOBAL PROCESS MANAGEMENT
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -203,9 +222,14 @@ cleanup_manager.register(dsc_messages)
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def require_login():
|
def require_login():
|
||||||
# Routes that don't require login (to avoid infinite redirect loop)
|
# Routes that don't require login (to avoid infinite redirect loop)
|
||||||
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
||||||
|
|
||||||
|
# Controller API endpoints use API key auth, not session auth
|
||||||
|
# Allow agent push/pull endpoints without session login
|
||||||
|
if request.path.startswith('/controller/'):
|
||||||
|
return None # Skip session check, controller routes handle their own auth
|
||||||
|
|
||||||
# If user is not logged in and the current route is not allowed...
|
# If user is not logged in and the current route is not allowed...
|
||||||
if 'logged_in' not in session and request.endpoint not in allowed_routes:
|
if 'logged_in' not in session and request.endpoint not in allowed_routes:
|
||||||
@@ -710,4 +734,4 @@ def main() -> None:
|
|||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
threaded=True,
|
threaded=True,
|
||||||
load_dotenv=False,
|
load_dotenv=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,10 +7,20 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.10.0"
|
VERSION = "2.11.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.11.0",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Meshtastic LoRa mesh network integration",
|
||||||
|
"Ubertooth One BLE scanning support",
|
||||||
|
"Offline mode with bundled assets",
|
||||||
|
"Settings modal with tile provider configuration",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"date": "January 2026",
|
"date": "January 2026",
|
||||||
@@ -126,18 +136,18 @@ AIRODUMP_HEADER_LINES = _get_env_int('AIRODUMP_HEADER_LINES', 2)
|
|||||||
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
|
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
|
||||||
BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
||||||
|
|
||||||
# ADS-B settings
|
# ADS-B settings
|
||||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||||
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
||||||
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
|
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
|
||||||
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
||||||
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
||||||
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
|
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
|
||||||
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
|
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
|
||||||
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
|
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
|
||||||
ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
|
ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
|
||||||
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
|
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
|
||||||
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
||||||
|
|
||||||
# Satellite settings
|
# Satellite settings
|
||||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
# Intercept Distributed Agent System
|
||||||
|
|
||||||
|
This document describes the distributed agent architecture that allows multiple remote sensor nodes to feed data into a central Intercept controller.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The agent system uses a hub-and-spoke architecture where:
|
||||||
|
- **Controller**: The main Intercept instance that aggregates data from multiple agents
|
||||||
|
- **Agents**: Lightweight sensor nodes running on remote devices with SDR hardware
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ INTERCEPT CONTROLLER │
|
||||||
|
│ (port 5050) │
|
||||||
|
│ │
|
||||||
|
│ - Web UI with agent selector │
|
||||||
|
│ - /controller/manage page │
|
||||||
|
│ - Multi-agent SSE stream │
|
||||||
|
│ - Push data storage │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
▲ ▲ ▲
|
||||||
|
│ │ │
|
||||||
|
Push/Pull │ │ │ Push/Pull
|
||||||
|
│ │ │
|
||||||
|
┌────┴───┐ ┌────┴───┐ ┌────┴───┐
|
||||||
|
│ Agent │ │ Agent │ │ Agent │
|
||||||
|
│ :8020 │ │ :8020 │ │ :8020 │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│[RTL-SDR] │[HackRF] │ │[LimeSDR]
|
||||||
|
└────────┘ └────────┘ └────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Start the Controller
|
||||||
|
|
||||||
|
The controller is the main Intercept application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd intercept
|
||||||
|
python app.py
|
||||||
|
# Runs on http://localhost:5050
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure an Agent
|
||||||
|
|
||||||
|
Create a config file on the remote machine:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# intercept_agent.cfg
|
||||||
|
[agent]
|
||||||
|
name = sensor-node-1
|
||||||
|
port = 8020
|
||||||
|
allowed_ips =
|
||||||
|
allow_cors = false
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
url = http://192.168.1.100:5050
|
||||||
|
api_key = your-secret-key-here
|
||||||
|
push_enabled = true
|
||||||
|
push_interval = 5
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
wifi = true
|
||||||
|
bluetooth = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the Agent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python intercept_agent.py --config intercept_agent.cfg
|
||||||
|
# Runs on http://localhost:8020
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Register the Agent
|
||||||
|
|
||||||
|
Go to `http://controller:5050/controller/manage` and add the agent:
|
||||||
|
- **Name**: sensor-node-1 (must match config)
|
||||||
|
- **Base URL**: http://agent-ip:8020
|
||||||
|
- **API Key**: your-secret-key-here (must match config)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
The system supports two data flow patterns:
|
||||||
|
|
||||||
|
#### Push (Agent → Controller)
|
||||||
|
|
||||||
|
Agents automatically push captured data to the controller:
|
||||||
|
|
||||||
|
1. Agent captures data (e.g., rtl_433 sensor readings)
|
||||||
|
2. Data is queued in the `ControllerPushClient`
|
||||||
|
3. Agent POSTs to `http://controller/controller/api/ingest`
|
||||||
|
4. Controller validates API key and stores in `push_payloads` table
|
||||||
|
5. Data is available via SSE stream at `/controller/stream/all`
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent Controller
|
||||||
|
│ │
|
||||||
|
│ POST /controller/api/ingest │
|
||||||
|
│ Header: X-API-Key: secret │
|
||||||
|
│ Body: {agent_name, scan_type, │
|
||||||
|
│ payload, timestamp} │
|
||||||
|
│ ──────────────────────────────► │
|
||||||
|
│ │
|
||||||
|
│ 200 OK │
|
||||||
|
│ ◄────────────────────────────── │
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pull (Controller → Agent)
|
||||||
|
|
||||||
|
The controller can also pull data on-demand:
|
||||||
|
|
||||||
|
1. User selects agent in UI dropdown
|
||||||
|
2. User clicks "Start Listening"
|
||||||
|
3. Controller proxies request to agent
|
||||||
|
4. Agent starts the mode and returns status
|
||||||
|
5. Controller polls agent for data
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Controller Agent
|
||||||
|
│ │ │
|
||||||
|
│ POST /controller/ │ │
|
||||||
|
│ agents/1/sensor/start│ │
|
||||||
|
│ ─────────────────────► │ │
|
||||||
|
│ │ POST /sensor/start │
|
||||||
|
│ │ ────────────────────────► │
|
||||||
|
│ │ │
|
||||||
|
│ │ {status: started} │
|
||||||
|
│ │ ◄──────────────────────── │
|
||||||
|
│ {status: success} │ │
|
||||||
|
│ ◄───────────────────── │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
API key authentication secures the push mechanism:
|
||||||
|
|
||||||
|
1. Agent config specifies `api_key` in `[controller]` section
|
||||||
|
2. Agent sends `X-API-Key` header with each push request
|
||||||
|
3. Controller looks up agent by name in database
|
||||||
|
4. Controller compares provided key with stored key
|
||||||
|
5. Mismatched keys return 401 Unauthorized
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
Two tables support the agent system:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Registered agents
|
||||||
|
CREATE TABLE agents (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
api_key TEXT,
|
||||||
|
capabilities TEXT, -- JSON: {pager: true, sensor: true, ...}
|
||||||
|
interfaces TEXT, -- JSON: {devices: [...]}
|
||||||
|
gps_coords TEXT, -- JSON: {lat, lon}
|
||||||
|
last_seen TIMESTAMP,
|
||||||
|
is_active BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pushed data from agents
|
||||||
|
CREATE TABLE push_payloads (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
agent_id INTEGER,
|
||||||
|
scan_type TEXT, -- pager, sensor, adsb, wifi, etc.
|
||||||
|
payload TEXT, -- JSON data
|
||||||
|
received_at TIMESTAMP,
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent REST API
|
||||||
|
|
||||||
|
The agent exposes these endpoints:
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Health check (returns `{status: "healthy"}`) |
|
||||||
|
| `/capabilities` | GET | Available modes, devices, GPS status |
|
||||||
|
| `/status` | GET | Running modes, uptime, push status |
|
||||||
|
| `/{mode}/start` | POST | Start a mode (pager, sensor, adsb, etc.) |
|
||||||
|
| `/{mode}/stop` | POST | Stop a mode |
|
||||||
|
| `/{mode}/status` | GET | Mode-specific status |
|
||||||
|
| `/{mode}/data` | GET | Current data snapshot |
|
||||||
|
|
||||||
|
### Example: Start Sensor Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://agent:8020/sensor/start \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"frequency": 433.92, "device_index": 0}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "started",
|
||||||
|
"mode": "sensor",
|
||||||
|
"command": "/usr/local/bin/rtl_433 -d 0 -f 433.92M -F json",
|
||||||
|
"gps_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Get Capabilities
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://agent:8020/capabilities
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modes": {
|
||||||
|
"pager": true,
|
||||||
|
"sensor": true,
|
||||||
|
"adsb": true,
|
||||||
|
"wifi": true,
|
||||||
|
"bluetooth": true
|
||||||
|
},
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"name": "RTLSDRBlog, Blog V4",
|
||||||
|
"sdr_type": "rtlsdr",
|
||||||
|
"capabilities": {
|
||||||
|
"freq_min_mhz": 24.0,
|
||||||
|
"freq_max_mhz": 1766.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gps": true,
|
||||||
|
"gps_position": {
|
||||||
|
"lat": 33.543,
|
||||||
|
"lon": -82.194,
|
||||||
|
"altitude": 70.0
|
||||||
|
},
|
||||||
|
"tool_details": {
|
||||||
|
"sensor": {
|
||||||
|
"name": "433MHz Sensors",
|
||||||
|
"ready": true,
|
||||||
|
"tools": {
|
||||||
|
"rtl_433": {"installed": true, "required": true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Modes
|
||||||
|
|
||||||
|
All modes are fully implemented in the agent with the following tools and data formats:
|
||||||
|
|
||||||
|
| Mode | Tool(s) | Data Format | Notes |
|
||||||
|
|------|---------|-------------|-------|
|
||||||
|
| `sensor` | rtl_433 | JSON readings | ISM band devices (433/868/915 MHz) |
|
||||||
|
| `pager` | rtl_fm + multimon-ng | POCSAG/FLEX messages | Address, function, message content |
|
||||||
|
| `adsb` | dump1090 | SBS-format aircraft | ICAO, callsign, position, altitude |
|
||||||
|
| `ais` | AIS-catcher | JSON vessels | MMSI, position, speed, vessel info |
|
||||||
|
| `acars` | acarsdec | JSON messages | Aircraft tail, label, message text |
|
||||||
|
| `aprs` | rtl_fm + direwolf | APRS packets | Callsign, position, path |
|
||||||
|
| `wifi` | airodump-ng | Networks + clients | BSSID, ESSID, signal, clients |
|
||||||
|
| `bluetooth` | bluetoothctl | Device list | MAC, name, RSSI |
|
||||||
|
| `rtlamr` | rtl_tcp + rtlamr | Meter readings | Meter ID, consumption data |
|
||||||
|
| `dsc` | rtl_fm (+ dsc-decoder) | DSC messages | MMSI, distress category, position |
|
||||||
|
| `tscm` | WiFi/BT analysis | Anomaly reports | New/rogue devices detected |
|
||||||
|
| `satellite` | skyfield (TLE) | Pass predictions | No SDR required |
|
||||||
|
| `listening_post` | rtl_fm scanner | Signal detections | Frequency, modulation |
|
||||||
|
|
||||||
|
### Mode-Specific Notes
|
||||||
|
|
||||||
|
**Listening Post**: Full FFT streaming isn't practical over HTTP. Instead, the agent provides:
|
||||||
|
- Signal detection events when activity is found
|
||||||
|
- Current scanning frequency
|
||||||
|
- Activity log of detected signals
|
||||||
|
|
||||||
|
**TSCM**: Analyzes WiFi and Bluetooth data for anomalies:
|
||||||
|
- Builds baseline of known devices
|
||||||
|
- Reports new/unknown devices as anomalies
|
||||||
|
- No SDR required (uses WiFi/BT data)
|
||||||
|
|
||||||
|
**Satellite**: Pure computational mode:
|
||||||
|
- Calculates pass predictions from TLE data
|
||||||
|
- Requires observer location (lat/lon)
|
||||||
|
- No SDR required
|
||||||
|
|
||||||
|
**Audio Modes**: Modes requiring real-time audio (airband, listening_post audio) are limited via agents. Use rtl_tcp for remote audio streaming instead.
|
||||||
|
|
||||||
|
## Controller API
|
||||||
|
|
||||||
|
### Agent Management
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/controller/agents` | GET | List all agents |
|
||||||
|
| `/controller/agents` | POST | Register new agent |
|
||||||
|
| `/controller/agents/{id}` | GET | Get agent details |
|
||||||
|
| `/controller/agents/{id}` | DELETE | Remove agent |
|
||||||
|
| `/controller/agents/{id}?refresh=true` | GET | Refresh agent capabilities |
|
||||||
|
|
||||||
|
### Proxy Operations
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/controller/agents/{id}/{mode}/start` | POST | Start mode on agent |
|
||||||
|
| `/controller/agents/{id}/{mode}/stop` | POST | Stop mode on agent |
|
||||||
|
| `/controller/agents/{id}/{mode}/data` | GET | Get data from agent |
|
||||||
|
|
||||||
|
### Push Ingestion
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/controller/api/ingest` | POST | Receive pushed data from agents |
|
||||||
|
|
||||||
|
### SSE Streams
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `/controller/stream/all` | Combined stream from all agents |
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Agent Selector
|
||||||
|
|
||||||
|
The main UI includes an agent dropdown in supported modes:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<select id="agentSelect">
|
||||||
|
<option value="local">Local (This Device)</option>
|
||||||
|
<option value="1">● sensor-node-1</option>
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
When an agent is selected:
|
||||||
|
1. Device list updates to show agent's SDR devices
|
||||||
|
2. Start/Stop commands route through controller proxy
|
||||||
|
3. Data displays with agent name badge
|
||||||
|
|
||||||
|
### Multi-Agent Mode
|
||||||
|
|
||||||
|
Enable "Show All Agents" checkbox to:
|
||||||
|
- Connect to `/controller/stream/all` SSE
|
||||||
|
- Display combined data from all agents
|
||||||
|
- Show agent name badge on each data item
|
||||||
|
|
||||||
|
## GPS Integration
|
||||||
|
|
||||||
|
Agents can include GPS coordinates with captured data:
|
||||||
|
|
||||||
|
1. Agent connects to local `gpsd` daemon
|
||||||
|
2. GPS position included in `/capabilities` and `/status`
|
||||||
|
3. Each data snapshot includes `agent_gps` field
|
||||||
|
4. Controller can use GPS for trilateration (multiple agents)
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### Agent Config (`intercept_agent.cfg`)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[agent]
|
||||||
|
# Agent identity (must be unique across all agents)
|
||||||
|
name = sensor-node-1
|
||||||
|
|
||||||
|
# Port to listen on
|
||||||
|
port = 8020
|
||||||
|
|
||||||
|
# Restrict connections to specific IPs (comma-separated, empty = all)
|
||||||
|
allowed_ips =
|
||||||
|
|
||||||
|
# Enable CORS headers
|
||||||
|
allow_cors = false
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
# Controller URL (required for push)
|
||||||
|
url = http://192.168.1.100:5050
|
||||||
|
|
||||||
|
# API key for authentication
|
||||||
|
api_key = your-secret-key
|
||||||
|
|
||||||
|
# Enable automatic data push
|
||||||
|
push_enabled = true
|
||||||
|
|
||||||
|
# Push interval in seconds
|
||||||
|
push_interval = 5
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
# Enable/disable specific modes
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
ais = true
|
||||||
|
wifi = true
|
||||||
|
bluetooth = true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Agent not appearing in controller
|
||||||
|
|
||||||
|
1. Check agent is running: `curl http://agent:8020/health`
|
||||||
|
2. Verify agent is registered in `/controller/manage`
|
||||||
|
3. Check API key matches between agent config and controller registration
|
||||||
|
4. Check network connectivity between agent and controller
|
||||||
|
|
||||||
|
### Push data not arriving
|
||||||
|
|
||||||
|
1. Check agent status: `curl http://agent:8020/status`
|
||||||
|
- Verify `push_enabled: true` and `push_connected: true`
|
||||||
|
2. Check controller logs for authentication errors
|
||||||
|
3. Verify API key matches
|
||||||
|
4. Check if mode is running and producing data
|
||||||
|
|
||||||
|
### Mode won't start on agent
|
||||||
|
|
||||||
|
1. Check capabilities: `curl http://agent:8020/capabilities`
|
||||||
|
2. Verify required tools are installed (check `tool_details`)
|
||||||
|
3. Check if SDR device is available (not in use by another process)
|
||||||
|
|
||||||
|
### No data from sensor mode
|
||||||
|
|
||||||
|
1. Verify rtl_433 is running: `ps aux | grep rtl_433`
|
||||||
|
2. Check sensor status: `curl http://agent:8020/sensor/status`
|
||||||
|
3. Note: Empty data is normal if no 433MHz devices are transmitting nearby
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **API Keys**: Always use strong, unique API keys for each agent
|
||||||
|
2. **Network**: Consider running agents on a private network or VPN
|
||||||
|
3. **HTTPS**: For production, use HTTPS between agents and controller
|
||||||
|
4. **Firewall**: Restrict agent ports to controller IP only
|
||||||
|
5. **allowed_ips**: Use this config option to restrict agent connections
|
||||||
|
|
||||||
|
## Dashboard Integration
|
||||||
|
|
||||||
|
Agent support has been integrated into the following specialized dashboards:
|
||||||
|
|
||||||
|
### ADS-B Dashboard (`/adsb/dashboard`)
|
||||||
|
- Agent selector in header bar
|
||||||
|
- Routes tracking start/stop through agent proxy when remote agent selected
|
||||||
|
- Connects to multi-agent stream for data from remote agents
|
||||||
|
- Displays agent badge on aircraft from remote sources
|
||||||
|
- Updates observer location from agent's GPS coordinates
|
||||||
|
|
||||||
|
### AIS Dashboard (`/ais/dashboard`)
|
||||||
|
- Agent selector in header bar
|
||||||
|
- Routes AIS and DSC mode operations through agent proxy
|
||||||
|
- Connects to multi-agent stream for vessel data
|
||||||
|
- Displays agent badge on vessels from remote sources
|
||||||
|
- Updates observer location from agent's GPS coordinates
|
||||||
|
|
||||||
|
### Main Dashboard (`/`)
|
||||||
|
- Agent selector in sidebar
|
||||||
|
- Supports sensor, pager, WiFi, Bluetooth modes via agents
|
||||||
|
- SDR conflict detection with device-aware warnings
|
||||||
|
- Real-time sync with agent's running mode state
|
||||||
|
|
||||||
|
### Multi-SDR Agent Support
|
||||||
|
|
||||||
|
For agents with multiple SDR devices, the system now tracks which device each mode is using:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"running_modes": ["sensor", "adsb"],
|
||||||
|
"running_modes_detail": {
|
||||||
|
"sensor": {"device": 0, "started_at": "2024-01-15T10:30:00Z"},
|
||||||
|
"adsb": {"device": 1, "started_at": "2024-01-15T10:35:00Z"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows:
|
||||||
|
- Smart conflict detection (only warns if same device is in use)
|
||||||
|
- Display of which device each mode is using
|
||||||
|
- Parallel operation of multiple SDR modes on multi-SDR agents
|
||||||
|
|
||||||
|
### Agent Mode Warnings
|
||||||
|
|
||||||
|
When an agent has SDR modes running, the UI displays:
|
||||||
|
- Warning banner showing active modes with device numbers
|
||||||
|
- Stop buttons for each running mode
|
||||||
|
- Refresh button to re-sync with agent state
|
||||||
|
|
||||||
|
### Pages Without Agent Support
|
||||||
|
|
||||||
|
The following pages don't require SDR-based agent support:
|
||||||
|
- **Satellite Dashboard** (`/satellite/dashboard`) - Uses TLE orbital calculations, no SDR
|
||||||
|
- **History pages** - Display stored data, not live SDR streams
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `intercept_agent.py` | Standalone agent server |
|
||||||
|
| `intercept_agent.cfg` | Agent configuration template |
|
||||||
|
| `routes/controller.py` | Controller API blueprint |
|
||||||
|
| `utils/agent_client.py` | HTTP client for agents |
|
||||||
|
| `utils/database.py` | Agent CRUD operations |
|
||||||
|
| `static/js/core/agents.js` | Frontend agent management |
|
||||||
|
| `templates/agents.html` | Agent management page |
|
||||||
|
| `templates/adsb_dashboard.html` | ADS-B page with agent integration |
|
||||||
|
| `templates/ais_dashboard.html` | AIS page with agent integration |
|
||||||
@@ -38,22 +38,22 @@ Complete feature list for all modules.
|
|||||||
- **Source links** - references to priyom.org for detailed information
|
- **Source links** - references to priyom.org for detailed information
|
||||||
- **Famous stations** - UVB-76 "The Buzzer", Cuban HM01, Israeli E17z
|
- **Famous stations** - UVB-76 "The Buzzer", Cuban HM01, Israeli E17z
|
||||||
|
|
||||||
## ADS-B Aircraft Tracking
|
## ADS-B Aircraft Tracking
|
||||||
|
|
||||||
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
|
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
|
||||||
- **Full-screen dashboard** - dedicated popout with virtual radar scope
|
- **Full-screen dashboard** - dedicated popout with virtual radar scope
|
||||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||||
- **Aircraft trails** - optional flight path history visualization
|
- **Aircraft trails** - optional flight path history visualization
|
||||||
- **Range rings** - distance reference circles from observer position
|
- **Range rings** - distance reference circles from observer position
|
||||||
- **Aircraft filtering** - show all, military only, civil only, or emergency only
|
- **Aircraft filtering** - show all, military only, civil only, or emergency only
|
||||||
- **Marker clustering** - group nearby aircraft at lower zoom levels
|
- **Marker clustering** - group nearby aircraft at lower zoom levels
|
||||||
- **Reception statistics** - max range, message rate, busiest hour, total seen
|
- **Reception statistics** - max range, message rate, busiest hour, total seen
|
||||||
- **Persistent ADS-B history** - optional Postgres-backed message and snapshot storage
|
- **Persistent ADS-B history** - optional Postgres-backed message and snapshot storage
|
||||||
- **History reporting dashboard** - session controls, aircraft timelines, and detail modal
|
- **History reporting dashboard** - session controls, aircraft timelines, and detail modal
|
||||||
- **Observer location** - manual input or GPS geolocation
|
- **Observer location** - manual input or GPS geolocation
|
||||||
- **Audio alerts** - notifications for military and emergency aircraft
|
- **Audio alerts** - notifications for military and emergency aircraft
|
||||||
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
||||||
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
|
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
||||||
@@ -165,6 +165,78 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
|||||||
- No cryptographic de-randomization
|
- No cryptographic de-randomization
|
||||||
- Passive screening only (no active probing by default)
|
- Passive screening only (no active probing by default)
|
||||||
|
|
||||||
|
## Meshtastic Mesh Networks
|
||||||
|
|
||||||
|
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
|
||||||
|
|
||||||
|
### Device Support
|
||||||
|
- **Heltec** - LoRa32 series
|
||||||
|
- **T-Beam** - TTGO T-Beam with GPS
|
||||||
|
- **RAK** - WisBlock series
|
||||||
|
- Any Meshtastic-compatible device via USB/Serial
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Real-time messaging** - Stream messages as they arrive
|
||||||
|
- **Channel configuration** - Set encryption keys and channel names
|
||||||
|
- **Node information** - View connected nodes with signal metrics
|
||||||
|
- **Message history** - Up to 500 messages retained
|
||||||
|
- **Signal quality** - RSSI and SNR for each message
|
||||||
|
- **Hop tracking** - See message hop count
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Physical Meshtastic device connected via USB
|
||||||
|
- Meshtastic Python SDK (`pip install meshtastic`)
|
||||||
|
|
||||||
|
## Ubertooth One BLE Scanning
|
||||||
|
|
||||||
|
Advanced Bluetooth Low Energy scanning using Ubertooth One hardware.
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
- **40-channel scanning** - Capture BLE advertisements across all channels
|
||||||
|
- **Raw payload access** - Full advertising data for analysis
|
||||||
|
- **Passive sniffing** - No active scanning required
|
||||||
|
- **MAC address extraction** - Public and random address types
|
||||||
|
- **RSSI measurement** - Signal strength for proximity estimation
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
- Works alongside standard BlueZ/DBus Bluetooth scanning
|
||||||
|
- Automatically detected when ubertooth-btle is available
|
||||||
|
- Falls back to standard adapter if Ubertooth not present
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Ubertooth One hardware
|
||||||
|
- ubertooth-btle command-line tool installed
|
||||||
|
- libubertooth library
|
||||||
|
|
||||||
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Hub-and-spoke model** - Central controller with multiple remote agents
|
||||||
|
- **Push and Pull modes** - Agents can push data automatically or respond to on-demand requests
|
||||||
|
- **API key authentication** - Secure communication between agents and controller
|
||||||
|
|
||||||
|
### Agent Features
|
||||||
|
- **Standalone deployment** - Run on Raspberry Pi, mini PCs, or any Linux device with SDR
|
||||||
|
- **All modes supported** - Pager, sensor, ADS-B, AIS, WiFi, Bluetooth, and more
|
||||||
|
- **GPS integration** - Automatic location tagging from USB GPS receivers
|
||||||
|
- **Multi-SDR support** - Run multiple modes simultaneously on agents with multiple SDRs
|
||||||
|
- **Capability discovery** - Controller auto-detects available modes and devices
|
||||||
|
|
||||||
|
### Controller Features
|
||||||
|
- **Agent management UI** - Register, test, and remove agents from `/controller/manage`
|
||||||
|
- **Real-time status** - Health monitoring with online/offline indicators
|
||||||
|
- **Unified data stream** - Aggregate data from all agents via SSE
|
||||||
|
- **Dashboard integration** - Agent selector in ADS-B, AIS, and main dashboards
|
||||||
|
- **Device conflict detection** - Smart warnings when SDR is in use
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
- **Wide-area monitoring** - Cover larger geographic areas with distributed sensors
|
||||||
|
- **Remote installations** - Deploy sensors in locations without direct access
|
||||||
|
- **Redundancy** - Multiple nodes for reliable coverage
|
||||||
|
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
||||||
|
|
||||||
## User Interface
|
## User Interface
|
||||||
|
|
||||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||||
@@ -186,6 +258,42 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
|||||||
| ? | Open help (when not typing) |
|
| ? | Open help (when not typing) |
|
||||||
| Escape | Close help/modals |
|
| Escape | Close help/modals |
|
||||||
|
|
||||||
|
## Offline Mode
|
||||||
|
|
||||||
|
Run iNTERCEPT without internet connectivity by using bundled local assets.
|
||||||
|
|
||||||
|
### Bundled Assets
|
||||||
|
- **Leaflet 1.9.4** - Map library with marker images
|
||||||
|
- **Chart.js 4.4.1** - Signal strength graphs
|
||||||
|
- **Inter font** - Primary UI font (400, 500, 600, 700 weights)
|
||||||
|
- **JetBrains Mono font** - Monospace/code font (400, 500, 600, 700 weights)
|
||||||
|
|
||||||
|
### Settings Modal
|
||||||
|
Access via the gear icon in the navigation bar:
|
||||||
|
- **Offline Tab** - Toggle offline mode, configure asset sources (CDN vs local)
|
||||||
|
- **Display Tab** - Theme and animation preferences
|
||||||
|
- **About Tab** - Version info and links
|
||||||
|
|
||||||
|
### Map Tile Providers
|
||||||
|
Choose from multiple tile sources for maps:
|
||||||
|
- **OpenStreetMap** - Default, general purpose
|
||||||
|
- **CartoDB Dark** - Dark themed, matches UI
|
||||||
|
- **CartoDB Positron** - Light themed
|
||||||
|
- **ESRI World Imagery** - Satellite imagery
|
||||||
|
- **Custom URL** - Connect to your own tile server (e.g., local OpenStreetMap tile cache)
|
||||||
|
|
||||||
|
### Local Asset Status
|
||||||
|
The settings modal shows availability status for each bundled asset:
|
||||||
|
- Green "Available" badge when asset is present
|
||||||
|
- Red "Missing" badge when asset is not found
|
||||||
|
- Click "Check Assets" to refresh status
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
- **Air-gapped environments** - Run on isolated networks
|
||||||
|
- **Field deployments** - Operate without reliable internet
|
||||||
|
- **Local tile servers** - Use pre-cached map tiles for specific regions
|
||||||
|
- **Reduced latency** - Faster loading with local assets
|
||||||
|
|
||||||
## General
|
## General
|
||||||
|
|
||||||
- **Web-based interface** - no desktop app needed
|
- **Web-based interface** - no desktop app needed
|
||||||
|
|||||||
@@ -74,42 +74,42 @@ INTERCEPT automatically detects known trackers:
|
|||||||
|
|
||||||
### Emergency Squawks
|
### Emergency Squawks
|
||||||
|
|
||||||
The system highlights aircraft transmitting emergency squawks:
|
The system highlights aircraft transmitting emergency squawks:
|
||||||
- **7500** - Hijack
|
- **7500** - Hijack
|
||||||
- **7600** - Radio failure
|
- **7600** - Radio failure
|
||||||
- **7700** - General emergency
|
- **7700** - General emergency
|
||||||
|
|
||||||
## ADS-B History (Optional)
|
## ADS-B History (Optional)
|
||||||
|
|
||||||
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||||
|
|
||||||
### Enable History
|
### Enable History
|
||||||
|
|
||||||
Set the following environment variables (Docker recommended):
|
Set the following environment variables (Docker recommended):
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
||||||
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
||||||
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
||||||
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
||||||
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
||||||
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
||||||
|
|
||||||
### Docker Setup
|
### Docker Setup
|
||||||
|
|
||||||
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using the History Dashboard
|
### Using the History Dashboard
|
||||||
|
|
||||||
1. Open **/adsb/history**
|
1. Open **/adsb/history**
|
||||||
2. Use **Start Tracking** to run ADS-B in headless mode
|
2. Use **Start Tracking** to run ADS-B in headless mode
|
||||||
3. View aircraft history and timelines
|
3. View aircraft history and timelines
|
||||||
4. Stop tracking when desired (session history is recorded)
|
4. Stop tracking when desired (session history is recorded)
|
||||||
|
|
||||||
## Satellite Mode
|
## Satellite Mode
|
||||||
|
|
||||||
@@ -130,6 +130,58 @@ docker compose up -d
|
|||||||
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
||||||
4. Select satellites to add
|
4. Select satellites to add
|
||||||
|
|
||||||
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
|
|
||||||
|
### Setting Up an Agent
|
||||||
|
|
||||||
|
1. **Install INTERCEPT** on the remote machine
|
||||||
|
2. **Create config file** (`intercept_agent.cfg`):
|
||||||
|
```ini
|
||||||
|
[agent]
|
||||||
|
name = sensor-node-1
|
||||||
|
port = 8020
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
url = http://192.168.1.100:5050
|
||||||
|
api_key = your-secret-key
|
||||||
|
push_enabled = true
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
```
|
||||||
|
3. **Start the agent**:
|
||||||
|
```bash
|
||||||
|
python intercept_agent.py --config intercept_agent.cfg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Agents in the Controller
|
||||||
|
|
||||||
|
1. Navigate to `/controller/manage` in the main INTERCEPT instance
|
||||||
|
2. Enter agent details:
|
||||||
|
- **Name**: Must match config file (e.g., `sensor-node-1`)
|
||||||
|
- **Base URL**: Agent address (e.g., `http://192.168.1.50:8020`)
|
||||||
|
- **API Key**: Must match config file
|
||||||
|
3. Click "Register Agent"
|
||||||
|
4. Use "Test" to verify connectivity
|
||||||
|
|
||||||
|
### Using Remote Agents
|
||||||
|
|
||||||
|
Once registered, agents appear in mode dropdowns:
|
||||||
|
|
||||||
|
1. **Select agent** from the dropdown in supported modes
|
||||||
|
2. **Start mode** - Commands are proxied to the remote agent
|
||||||
|
3. **View data** - Data streams back to your browser via SSE
|
||||||
|
|
||||||
|
### Multi-Agent Streaming
|
||||||
|
|
||||||
|
Enable "Show All Agents" to aggregate data from all registered agents simultaneously.
|
||||||
|
|
||||||
|
For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
INTERCEPT can be configured via environment variables:
|
INTERCEPT can be configured via environment variables:
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ exclude:
|
|||||||
- USAGE.md
|
- USAGE.md
|
||||||
- FEATURES.md
|
- FEATURES.md
|
||||||
- HARDWARE.md
|
- HARDWARE.md
|
||||||
|
- DISTRIBUTED_AGENTS.md
|
||||||
|
|||||||
|
After Width: | Height: | Size: 466 KiB |
|
Before Width: | Height: | Size: 642 KiB After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 210 KiB |
@@ -18,6 +18,7 @@
|
|||||||
<a href="#features">Features</a>
|
<a href="#features">Features</a>
|
||||||
<a href="#screenshots">Screenshots</a>
|
<a href="#screenshots">Screenshots</a>
|
||||||
<a href="#installation">Install</a>
|
<a href="#installation">Install</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||||
<a href="https://github.com/smittix/intercept" class="nav-btn" target="_blank">GitHub</a>
|
<a href="https://github.com/smittix/intercept" class="nav-btn" target="_blank">GitHub</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,6 +124,24 @@
|
|||||||
<h3>Spy Stations</h3>
|
<h3>Spy Stations</h3>
|
||||||
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🌐</div>
|
||||||
|
<h3>Remote Agents</h3>
|
||||||
|
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📴</div>
|
||||||
|
<h3>Offline Mode</h3>
|
||||||
|
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📡</div>
|
||||||
|
<h3>Meshtastic</h3>
|
||||||
|
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -161,6 +180,14 @@
|
|||||||
<img src="images/tscm-detail.png" alt="Device Detail Dialog">
|
<img src="images/tscm-detail.png" alt="Device Detail Dialog">
|
||||||
<span class="screenshot-label">Device Analysis</span>
|
<span class="screenshot-label">Device Analysis</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/remote-agents.png" alt="Remote Agents Management">
|
||||||
|
<span class="screenshot-label">Remote Agents</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
||||||
|
<span class="screenshot-label">AIS Vessel Tracking</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -255,7 +282,8 @@ docker compose up -d</code></pre>
|
|||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
||||||
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
||||||
<a href="USAGE.html">Documentation</a>
|
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# INTERCEPT AGENT CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# This file configures the Intercept remote agent.
|
||||||
|
# Copy this file and customize for your deployment.
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
# Agent name (used to identify this node in the controller)
|
||||||
|
# Default: system hostname
|
||||||
|
name = sensor-node-1
|
||||||
|
|
||||||
|
# HTTP server port
|
||||||
|
# Default: 8020
|
||||||
|
port = 8020
|
||||||
|
|
||||||
|
# Comma-separated list of allowed client IPs (empty = allow all)
|
||||||
|
# Example: 192.168.1.100, 192.168.1.101, 10.0.0.0/8
|
||||||
|
allowed_ips =
|
||||||
|
|
||||||
|
# Enable CORS headers for browser-based clients
|
||||||
|
# Default: false
|
||||||
|
allow_cors = false
|
||||||
|
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
# Controller URL for push mode
|
||||||
|
# Example: http://192.168.1.100:5050
|
||||||
|
url =
|
||||||
|
|
||||||
|
# API key for controller authentication (shared secret)
|
||||||
|
api_key =
|
||||||
|
|
||||||
|
# Enable automatic push of scan data to controller
|
||||||
|
# Default: false
|
||||||
|
push_enabled = false
|
||||||
|
|
||||||
|
# Push interval in seconds (minimum time between pushes)
|
||||||
|
# Default: 5
|
||||||
|
push_interval = 5
|
||||||
|
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
# Enable/disable specific modes on this agent
|
||||||
|
# Set to false to disable a mode even if tools are available
|
||||||
|
# Default: all true
|
||||||
|
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
ais = true
|
||||||
|
acars = true
|
||||||
|
aprs = true
|
||||||
|
wifi = true
|
||||||
|
bluetooth = true
|
||||||
|
dsc = true
|
||||||
|
rtlamr = true
|
||||||
|
tscm = true
|
||||||
|
satellite = true
|
||||||
|
listening_post = true
|
||||||
@@ -20,6 +20,9 @@ numpy>=1.24.0
|
|||||||
# GPS dongle support (optional - only needed for USB GPS receivers)
|
# GPS dongle support (optional - only needed for USB GPS receivers)
|
||||||
pyserial>=3.5
|
pyserial>=3.5
|
||||||
|
|
||||||
|
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||||
|
meshtastic>=2.0.0
|
||||||
|
|
||||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||||
# pytest>=7.0.0
|
# pytest>=7.0.0
|
||||||
# pytest-cov>=4.0.0
|
# pytest-cov>=4.0.0
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ def register_blueprints(app):
|
|||||||
from .settings import settings_bp
|
from .settings import settings_bp
|
||||||
from .correlation import correlation_bp
|
from .correlation import correlation_bp
|
||||||
from .listening_post import listening_post_bp
|
from .listening_post import listening_post_bp
|
||||||
|
from .meshtastic import meshtastic_bp
|
||||||
from .tscm import tscm_bp, init_tscm_state
|
from .tscm import tscm_bp, init_tscm_state
|
||||||
from .spy_stations import spy_stations_bp
|
from .spy_stations import spy_stations_bp
|
||||||
|
from .controller import controller_bp
|
||||||
|
from .offline import offline_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -39,8 +42,11 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(correlation_bp)
|
app.register_blueprint(correlation_bp)
|
||||||
app.register_blueprint(listening_post_bp)
|
app.register_blueprint(listening_post_bp)
|
||||||
|
app.register_blueprint(meshtastic_bp)
|
||||||
app.register_blueprint(tscm_bp)
|
app.register_blueprint(tscm_bp)
|
||||||
app.register_blueprint(spy_stations_bp)
|
app.register_blueprint(spy_stations_bp)
|
||||||
|
app.register_blueprint(controller_bp) # Remote agent controller
|
||||||
|
app.register_blueprint(offline_bp) # Offline mode settings
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -52,11 +52,13 @@ def find_acarsdec():
|
|||||||
def get_acarsdec_json_flag(acarsdec_path: str) -> str:
|
def get_acarsdec_json_flag(acarsdec_path: str) -> str:
|
||||||
"""Detect which JSON output flag acarsdec supports.
|
"""Detect which JSON output flag acarsdec supports.
|
||||||
|
|
||||||
Version 4.0+ uses -j for JSON stdout.
|
Different forks use different flags:
|
||||||
Version 3.x uses -o 4 for JSON stdout.
|
- TLeconte v4.0+: uses -j for JSON stdout
|
||||||
|
- TLeconte v3.x: uses -o 4 for JSON stdout
|
||||||
|
- f00b4r0 fork (DragonOS): uses --output json:file:- for JSON stdout
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get version by running acarsdec with no args (shows usage with version)
|
# Get help/version by running acarsdec with no args (shows usage)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[acarsdec_path],
|
[acarsdec_path],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -65,8 +67,15 @@ def get_acarsdec_json_flag(acarsdec_path: str) -> str:
|
|||||||
)
|
)
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
# Parse version from output like "Acarsdec v4.3.1" or "Acarsdec/acarsserv 3.7"
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
# Check for f00b4r0 fork signature: uses --output instead of -j/-o
|
||||||
|
# f00b4r0's help shows "--output" for output configuration
|
||||||
|
if '--output' in output or 'json:file:' in output.lower():
|
||||||
|
logger.debug("Detected f00b4r0 acarsdec fork (--output syntax)")
|
||||||
|
return '--output'
|
||||||
|
|
||||||
|
# Parse version from output like "Acarsdec v4.3.1" or "Acarsdec/acarsserv 3.7"
|
||||||
version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE)
|
version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE)
|
||||||
if version_match:
|
if version_match:
|
||||||
major = int(version_match.group(1))
|
major = int(version_match.group(1))
|
||||||
@@ -79,7 +88,7 @@ def get_acarsdec_json_flag(acarsdec_path: str) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not detect acarsdec version: {e}")
|
logger.debug(f"Could not detect acarsdec version: {e}")
|
||||||
|
|
||||||
# Default to -j (modern standard for current builds from source)
|
# Default to -j (TLeconte modern standard)
|
||||||
return '-j'
|
return '-j'
|
||||||
|
|
||||||
|
|
||||||
@@ -210,15 +219,20 @@ def start_acars() -> Response:
|
|||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
# Build acarsdec command
|
# Build acarsdec command
|
||||||
# acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# Different forks have different syntax:
|
||||||
# Note: -j is JSON stdout (newer forks), -o 4 was the old syntax
|
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# gain/ppm must come BEFORE -r
|
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
|
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||||
|
# Note: gain/ppm must come BEFORE -r
|
||||||
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||||
cmd = [acarsdec_path]
|
cmd = [acarsdec_path]
|
||||||
if json_flag == '-j':
|
if json_flag == '--output':
|
||||||
cmd.append('-j') # JSON output (newer TLeconte fork)
|
# f00b4r0 fork: --output json:file (no path = stdout)
|
||||||
|
cmd.extend(['--output', 'json:file'])
|
||||||
|
elif json_flag == '-j':
|
||||||
|
cmd.append('-j') # JSON output (TLeconte v4+)
|
||||||
else:
|
else:
|
||||||
cmd.extend(['-o', '4']) # JSON output (older versions)
|
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
||||||
|
|
||||||
# Add gain if not auto (must be before -r)
|
# Add gain if not auto (must be before -r)
|
||||||
if gain and str(gain) != '0':
|
if gain and str(gain) != '0':
|
||||||
@@ -228,8 +242,14 @@ def start_acars() -> Response:
|
|||||||
if ppm and str(ppm) != '0':
|
if ppm and str(ppm) != '0':
|
||||||
cmd.extend(['-p', str(ppm)])
|
cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
# Add device and frequencies (-r takes device, remaining args are frequencies)
|
# Add device and frequencies
|
||||||
cmd.extend(['-r', str(device)])
|
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
||||||
|
if json_flag == '--output':
|
||||||
|
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||||
|
cmd.extend(['-m', '256'])
|
||||||
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
|
else:
|
||||||
|
cmd.extend(['-r', str(device)])
|
||||||
cmd.extend(frequencies)
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
logger.info(f"Starting ACARS decoder: {' '.join(cmd)}")
|
logger.info(f"Starting ACARS decoder: {' '.join(cmd)}")
|
||||||
|
|||||||
@@ -163,10 +163,13 @@ def process_ais_message(msg: dict) -> dict | None:
|
|||||||
vessel = app_module.ais_vessels.get(mmsi) or {'mmsi': mmsi}
|
vessel = app_module.ais_vessels.get(mmsi) or {'mmsi': mmsi}
|
||||||
|
|
||||||
# Extract common fields
|
# Extract common fields
|
||||||
if 'lat' in msg and 'lon' in msg:
|
# AIS-catcher JSON_FULL uses 'longitude'/'latitude', but some versions use 'lon'/'lat'
|
||||||
|
lat_val = msg.get('latitude') or msg.get('lat')
|
||||||
|
lon_val = msg.get('longitude') or msg.get('lon')
|
||||||
|
if lat_val is not None and lon_val is not None:
|
||||||
try:
|
try:
|
||||||
lat = float(msg['lat'])
|
lat = float(lat_val)
|
||||||
lon = float(msg['lon'])
|
lon = float(lon_val)
|
||||||
# Validate coordinates (AIS uses 181 for unavailable)
|
# Validate coordinates (AIS uses 181 for unavailable)
|
||||||
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
||||||
vessel['lat'] = lat
|
vessel['lat'] = lat
|
||||||
|
|||||||
@@ -0,0 +1,788 @@
|
|||||||
|
"""
|
||||||
|
Controller routes for managing remote Intercept agents.
|
||||||
|
|
||||||
|
This blueprint provides:
|
||||||
|
- Agent CRUD operations
|
||||||
|
- Proxy endpoints to forward requests to agents
|
||||||
|
- Push data ingestion endpoint
|
||||||
|
- Multi-agent SSE stream
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.database import (
|
||||||
|
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||||
|
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
||||||
|
)
|
||||||
|
from utils.agent_client import (
|
||||||
|
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
|
||||||
|
)
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.trilateration import (
|
||||||
|
DeviceLocationTracker, PathLossModel, Trilateration,
|
||||||
|
AgentObservation, estimate_location_from_observations
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.controller')
|
||||||
|
|
||||||
|
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||||
|
|
||||||
|
# Multi-agent data queue for combined SSE stream
|
||||||
|
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent CRUD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/agents', methods=['GET'])
|
||||||
|
def get_agents():
|
||||||
|
"""List all registered agents."""
|
||||||
|
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
||||||
|
agents = list_agents(active_only=active_only)
|
||||||
|
|
||||||
|
# Optionally refresh status for each agent
|
||||||
|
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||||
|
if refresh:
|
||||||
|
for agent in agents:
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
agent['healthy'] = client.health_check()
|
||||||
|
except Exception:
|
||||||
|
agent['healthy'] = False
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agents': agents,
|
||||||
|
'count': len(agents)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents', methods=['POST'])
|
||||||
|
def register_agent():
|
||||||
|
"""
|
||||||
|
Register a new remote agent.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"name": "sensor-node-1",
|
||||||
|
"base_url": "http://192.168.1.50:8020",
|
||||||
|
"api_key": "optional-shared-secret",
|
||||||
|
"description": "Optional description"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
base_url = data.get('base_url', '').strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent name is required'}), 400
|
||||||
|
if not base_url:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
|
||||||
|
|
||||||
|
# Check if agent already exists
|
||||||
|
existing = get_agent_by_name(name)
|
||||||
|
if existing:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent with name "{name}" already exists'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Try to connect and get capabilities
|
||||||
|
api_key = data.get('api_key', '').strip() or None
|
||||||
|
client = AgentClient(base_url, api_key=api_key)
|
||||||
|
|
||||||
|
capabilities = None
|
||||||
|
interfaces = None
|
||||||
|
try:
|
||||||
|
caps = client.get_capabilities()
|
||||||
|
capabilities = caps.get('modes', {})
|
||||||
|
interfaces = {'devices': caps.get('devices', [])}
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
logger.warning(f"Could not fetch capabilities from {base_url}: {e}")
|
||||||
|
|
||||||
|
# Create agent
|
||||||
|
try:
|
||||||
|
agent_id = create_agent(
|
||||||
|
name=name,
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
description=data.get('description'),
|
||||||
|
capabilities=capabilities,
|
||||||
|
interfaces=interfaces
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last_seen since we just connected
|
||||||
|
if capabilities is not None:
|
||||||
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Agent registered successfully',
|
||||||
|
'agent': agent
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to create agent")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
|
||||||
|
def get_agent_detail(agent_id: int):
|
||||||
|
"""Get details of a specific agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
# Optionally refresh from agent
|
||||||
|
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||||
|
if refresh:
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
metadata = client.refresh_metadata()
|
||||||
|
if metadata['healthy']:
|
||||||
|
caps = metadata['capabilities'] or {}
|
||||||
|
# Store full interfaces structure (wifi, bt, sdr)
|
||||||
|
agent_interfaces = caps.get('interfaces', {})
|
||||||
|
# Fallback: also include top-level devices for backwards compatibility
|
||||||
|
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
|
||||||
|
agent_interfaces['sdr_devices'] = caps.get('devices', [])
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
capabilities=caps.get('modes'),
|
||||||
|
interfaces=agent_interfaces,
|
||||||
|
update_last_seen=True
|
||||||
|
)
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
agent['healthy'] = True
|
||||||
|
else:
|
||||||
|
agent['healthy'] = False
|
||||||
|
except Exception:
|
||||||
|
agent['healthy'] = False
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'agent': agent})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>', methods=['PUT', 'PATCH'])
|
||||||
|
def update_agent_detail(agent_id: int):
|
||||||
|
"""Update an agent's details."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Update allowed fields
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
base_url=data.get('base_url'),
|
||||||
|
description=data.get('description'),
|
||||||
|
api_key=data.get('api_key'),
|
||||||
|
is_active=data.get('is_active')
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
return jsonify({'status': 'success', 'agent': agent})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>', methods=['DELETE'])
|
||||||
|
def remove_agent(agent_id: int):
|
||||||
|
"""Delete an agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
delete_agent(agent_id)
|
||||||
|
return jsonify({'status': 'success', 'message': 'Agent deleted'})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/refresh', methods=['POST'])
|
||||||
|
def refresh_agent_metadata(agent_id: int):
|
||||||
|
"""Refresh an agent's capabilities and status."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
metadata = client.refresh_metadata()
|
||||||
|
|
||||||
|
if metadata['healthy']:
|
||||||
|
caps = metadata['capabilities'] or {}
|
||||||
|
# Store full interfaces structure (wifi, bt, sdr)
|
||||||
|
agent_interfaces = caps.get('interfaces', {})
|
||||||
|
# Fallback: also include top-level devices for backwards compatibility
|
||||||
|
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
|
||||||
|
agent_interfaces['sdr_devices'] = caps.get('devices', [])
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
capabilities=caps.get('modes'),
|
||||||
|
interfaces=agent_interfaces,
|
||||||
|
update_last_seen=True
|
||||||
|
)
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent': agent,
|
||||||
|
'metadata': metadata
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Agent is not reachable'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to reach agent: {e}'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent Status - Get running state
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/status', methods=['GET'])
|
||||||
|
def get_agent_status(agent_id: int):
|
||||||
|
"""Get an agent's current status including running modes."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
status = client.get_status()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'agent_name': agent['name'],
|
||||||
|
'agent_status': status
|
||||||
|
})
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to reach agent: {e}'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/health', methods=['GET'])
|
||||||
|
def check_all_agents_health():
|
||||||
|
"""
|
||||||
|
Check health of all registered agents in one call.
|
||||||
|
|
||||||
|
More efficient than checking each agent individually.
|
||||||
|
Returns health status, response time, and running modes for each agent.
|
||||||
|
"""
|
||||||
|
agents_list = list_agents(active_only=True)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for agent in agents_list:
|
||||||
|
result = {
|
||||||
|
'id': agent['id'],
|
||||||
|
'name': agent['name'],
|
||||||
|
'healthy': False,
|
||||||
|
'response_time_ms': None,
|
||||||
|
'running_modes': [],
|
||||||
|
'error': None
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
|
||||||
|
# Time the health check
|
||||||
|
start_time = time.time()
|
||||||
|
is_healthy = client.health_check()
|
||||||
|
response_time = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
result['healthy'] = is_healthy
|
||||||
|
result['response_time_ms'] = round(response_time, 1)
|
||||||
|
|
||||||
|
if is_healthy:
|
||||||
|
# Update last_seen in database
|
||||||
|
update_agent(agent['id'], update_last_seen=True)
|
||||||
|
|
||||||
|
# Also fetch running modes
|
||||||
|
try:
|
||||||
|
status = client.get_status()
|
||||||
|
result['running_modes'] = status.get('running_modes', [])
|
||||||
|
result['running_modes_detail'] = status.get('running_modes_detail', {})
|
||||||
|
except Exception:
|
||||||
|
pass # Status fetch is optional
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
result['error'] = f'Connection failed: {str(e)}'
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
result['error'] = f'HTTP error: {str(e)}'
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'agents': results,
|
||||||
|
'total': len(results),
|
||||||
|
'healthy_count': sum(1 for r in results if r['healthy'])
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Proxy Operations - Forward requests to agents
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/start', methods=['POST'])
|
||||||
|
def proxy_start_mode(agent_id: int, mode: str):
|
||||||
|
"""Start a mode on a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
params = request.json or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.start_mode(mode, params)
|
||||||
|
|
||||||
|
# Update last_seen
|
||||||
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'mode': mode,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Cannot connect to agent: {e}'
|
||||||
|
}), 503
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
|
||||||
|
def proxy_stop_mode(agent_id: int, mode: str):
|
||||||
|
"""Stop a mode on a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.stop_mode(mode)
|
||||||
|
|
||||||
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'mode': mode,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Cannot connect to agent: {e}'
|
||||||
|
}), 503
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
|
||||||
|
def proxy_mode_status(agent_id: int, mode: str):
|
||||||
|
"""Get mode status from a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.get_mode_status(mode)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'mode': mode,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||||
|
def proxy_mode_data(agent_id: int, mode: str):
|
||||||
|
"""Get current data from a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.get_mode_data(mode)
|
||||||
|
|
||||||
|
# Tag data with agent info
|
||||||
|
result['agent_id'] = agent_id
|
||||||
|
result['agent_name'] = agent['name']
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'agent_name': agent['name'],
|
||||||
|
'mode': mode,
|
||||||
|
'data': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Agent error: {e}'
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Push Data Ingestion
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/api/ingest', methods=['POST'])
|
||||||
|
def ingest_push_data():
|
||||||
|
"""
|
||||||
|
Receive pushed data from remote agents.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"agent_name": "sensor-node-1",
|
||||||
|
"scan_type": "adsb",
|
||||||
|
"interface": "rtlsdr0",
|
||||||
|
"payload": {...},
|
||||||
|
"received_at": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
Expected header:
|
||||||
|
X-API-Key: shared-secret (if agent has api_key configured)
|
||||||
|
"""
|
||||||
|
data = request.json
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||||
|
|
||||||
|
agent_name = data.get('agent_name')
|
||||||
|
if not agent_name:
|
||||||
|
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400
|
||||||
|
|
||||||
|
# Find agent
|
||||||
|
agent = get_agent_by_name(agent_name)
|
||||||
|
if not agent:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401
|
||||||
|
|
||||||
|
# Validate API key if configured
|
||||||
|
if agent.get('api_key'):
|
||||||
|
provided_key = request.headers.get('X-API-Key', '')
|
||||||
|
if provided_key != agent['api_key']:
|
||||||
|
logger.warning(f"Invalid API key from agent {agent_name}")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid API key'}), 401
|
||||||
|
|
||||||
|
# Store payload
|
||||||
|
try:
|
||||||
|
payload_id = store_push_payload(
|
||||||
|
agent_id=agent['id'],
|
||||||
|
scan_type=data.get('scan_type', 'unknown'),
|
||||||
|
payload=data.get('payload', {}),
|
||||||
|
interface=data.get('interface'),
|
||||||
|
received_at=data.get('received_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Emit to SSE stream
|
||||||
|
try:
|
||||||
|
agent_data_queue.put_nowait({
|
||||||
|
'type': 'agent_data',
|
||||||
|
'agent_id': agent['id'],
|
||||||
|
'agent_name': agent_name,
|
||||||
|
'scan_type': data.get('scan_type'),
|
||||||
|
'interface': data.get('interface'),
|
||||||
|
'payload': data.get('payload'),
|
||||||
|
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
logger.warning("Agent data queue full, data may be lost")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'accepted',
|
||||||
|
'payload_id': payload_id
|
||||||
|
}), 202
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to store push payload")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/payloads', methods=['GET'])
|
||||||
|
def get_payloads():
|
||||||
|
"""Get recent push payloads."""
|
||||||
|
agent_id = request.args.get('agent_id', type=int)
|
||||||
|
scan_type = request.args.get('scan_type')
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
|
||||||
|
payloads = get_recent_payloads(
|
||||||
|
agent_id=agent_id,
|
||||||
|
scan_type=scan_type,
|
||||||
|
limit=min(limit, 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'payloads': payloads,
|
||||||
|
'count': len(payloads)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Multi-Agent SSE Stream
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/stream/all')
|
||||||
|
def stream_all_agents():
|
||||||
|
"""
|
||||||
|
Combined SSE stream for data from all agents.
|
||||||
|
|
||||||
|
This endpoint streams push data as it arrives from agents.
|
||||||
|
Each message is tagged with agent_id and agent_name.
|
||||||
|
"""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = agent_data_queue.get(timeout=1.0)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent Management Page
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/manage')
|
||||||
|
def agent_management_page():
|
||||||
|
"""Render the agent management page."""
|
||||||
|
from flask import render_template
|
||||||
|
from config import VERSION
|
||||||
|
return render_template('agents.html', version=VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/monitor')
|
||||||
|
def network_monitor_page():
|
||||||
|
"""Render the network monitor page for multi-agent aggregated view."""
|
||||||
|
from flask import render_template
|
||||||
|
return render_template('network_monitor.html')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Device Location Estimation (Trilateration)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Global device location tracker
|
||||||
|
device_tracker = DeviceLocationTracker(
|
||||||
|
trilateration=Trilateration(
|
||||||
|
path_loss_model=PathLossModel('outdoor'),
|
||||||
|
min_observations=2
|
||||||
|
),
|
||||||
|
observation_window_seconds=120.0, # 2 minute window
|
||||||
|
min_observations=2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/observe', methods=['POST'])
|
||||||
|
def add_location_observation():
|
||||||
|
"""
|
||||||
|
Add an observation for device location estimation.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"agent_name": "sensor-node-1",
|
||||||
|
"agent_lat": 40.7128,
|
||||||
|
"agent_lon": -74.0060,
|
||||||
|
"rssi": -55,
|
||||||
|
"frequency_mhz": 2400 (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns location estimate if enough data, null otherwise.
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
|
||||||
|
for field in required:
|
||||||
|
if field not in data:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Missing required field: {field}'}), 400
|
||||||
|
|
||||||
|
# Look up agent GPS from database if not provided
|
||||||
|
agent_lat = data.get('agent_lat')
|
||||||
|
agent_lon = data.get('agent_lon')
|
||||||
|
|
||||||
|
if agent_lat is None or agent_lon is None:
|
||||||
|
agent = get_agent_by_name(data['agent_name'])
|
||||||
|
if agent and agent.get('gps_coords'):
|
||||||
|
coords = agent['gps_coords']
|
||||||
|
agent_lat = coords.get('lat') or coords.get('latitude')
|
||||||
|
agent_lon = coords.get('lon') or coords.get('longitude')
|
||||||
|
|
||||||
|
if agent_lat is None or agent_lon is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Agent GPS coordinates required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
estimate = device_tracker.add_observation(
|
||||||
|
device_id=data['device_id'],
|
||||||
|
agent_name=data['agent_name'],
|
||||||
|
agent_lat=float(agent_lat),
|
||||||
|
agent_lon=float(agent_lon),
|
||||||
|
rssi=float(data['rssi']),
|
||||||
|
frequency_mhz=data.get('frequency_mhz')
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'device_id': data['device_id'],
|
||||||
|
'location': estimate.to_dict() if estimate else None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/estimate', methods=['POST'])
|
||||||
|
def estimate_location():
|
||||||
|
"""
|
||||||
|
Estimate device location from provided observations.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"observations": [
|
||||||
|
{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"},
|
||||||
|
{"agent_lat": 40.7135, "agent_lon": -74.0055, "rssi": -70, "agent_name": "node-2"},
|
||||||
|
{"agent_lat": 40.7120, "agent_lon": -74.0050, "rssi": -62, "agent_name": "node-3"}
|
||||||
|
],
|
||||||
|
"environment": "outdoor" (optional: outdoor, indoor, free_space)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
observations = data.get('observations', [])
|
||||||
|
if len(observations) < 2:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'At least 2 observations required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
environment = data.get('environment', 'outdoor')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = estimate_location_from_observations(observations, environment)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if result else 'insufficient_data',
|
||||||
|
'location': result
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Location estimation failed")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
|
||||||
|
def get_device_location(device_id: str):
|
||||||
|
"""Get the latest location estimate for a device."""
|
||||||
|
estimate = device_tracker.get_location(device_id)
|
||||||
|
|
||||||
|
if not estimate:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'not_found',
|
||||||
|
'device_id': device_id,
|
||||||
|
'location': None
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'device_id': device_id,
|
||||||
|
'location': estimate.to_dict()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/all', methods=['GET'])
|
||||||
|
def get_all_locations():
|
||||||
|
"""Get all current device location estimates."""
|
||||||
|
locations = device_tracker.get_all_locations()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'count': len(locations),
|
||||||
|
'devices': {
|
||||||
|
device_id: estimate.to_dict()
|
||||||
|
for device_id, estimate in locations.items()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/near', methods=['GET'])
|
||||||
|
def get_devices_near():
|
||||||
|
"""
|
||||||
|
Find devices near a location.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
lat: latitude
|
||||||
|
lon: longitude
|
||||||
|
radius: radius in meters (default 100)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lat = float(request.args.get('lat', 0))
|
||||||
|
lon = float(request.args.get('lon', 0))
|
||||||
|
radius = float(request.args.get('radius', 100))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||||
|
|
||||||
|
results = device_tracker.get_devices_near(lat, lon, radius)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'center': {'lat': lat, 'lon': lon},
|
||||||
|
'radius_meters': radius,
|
||||||
|
'count': len(results),
|
||||||
|
'devices': [
|
||||||
|
{'device_id': device_id, 'location': estimate.to_dict()}
|
||||||
|
for device_id, estimate in results
|
||||||
|
]
|
||||||
|
})
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
"""Meshtastic mesh network routes.
|
||||||
|
|
||||||
|
Provides endpoints for connecting to Meshtastic devices, configuring
|
||||||
|
channels with encryption keys, and streaming received messages.
|
||||||
|
|
||||||
|
Requires a physical Meshtastic device (Heltec, T-Beam, RAK, etc.)
|
||||||
|
connected via USB/Serial.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.meshtastic import (
|
||||||
|
get_meshtastic_client,
|
||||||
|
start_meshtastic,
|
||||||
|
stop_meshtastic,
|
||||||
|
is_meshtastic_available,
|
||||||
|
MeshtasticMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.meshtastic')
|
||||||
|
|
||||||
|
meshtastic_bp = Blueprint('meshtastic', __name__, url_prefix='/meshtastic')
|
||||||
|
|
||||||
|
# Queue for SSE message streaming
|
||||||
|
_mesh_queue: queue.Queue = queue.Queue(maxsize=500)
|
||||||
|
|
||||||
|
# Store recent messages for history
|
||||||
|
_recent_messages: list[dict] = []
|
||||||
|
MAX_HISTORY = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _message_callback(msg: MeshtasticMessage) -> None:
|
||||||
|
"""Callback to queue messages for SSE stream."""
|
||||||
|
msg_dict = msg.to_dict()
|
||||||
|
|
||||||
|
# Add to history
|
||||||
|
_recent_messages.append(msg_dict)
|
||||||
|
if len(_recent_messages) > MAX_HISTORY:
|
||||||
|
_recent_messages.pop(0)
|
||||||
|
|
||||||
|
# Queue for SSE
|
||||||
|
try:
|
||||||
|
_mesh_queue.put_nowait(msg_dict)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_mesh_queue.get_nowait()
|
||||||
|
_mesh_queue.put_nowait(msg_dict)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/status')
|
||||||
|
def get_status():
|
||||||
|
"""
|
||||||
|
Get Meshtastic connection status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with connection status, device info, and node information.
|
||||||
|
"""
|
||||||
|
if not is_meshtastic_available():
|
||||||
|
return jsonify({
|
||||||
|
'available': False,
|
||||||
|
'running': False,
|
||||||
|
'error': 'Meshtastic SDK not installed. Install with: pip install meshtastic'
|
||||||
|
})
|
||||||
|
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client:
|
||||||
|
return jsonify({
|
||||||
|
'available': True,
|
||||||
|
'running': False,
|
||||||
|
'device': None,
|
||||||
|
'node_info': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
node_info = client.get_node_info() if client.is_running else None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'available': True,
|
||||||
|
'running': client.is_running,
|
||||||
|
'device': client.device_path,
|
||||||
|
'error': client.error,
|
||||||
|
'node_info': node_info.to_dict() if node_info else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/start', methods=['POST'])
|
||||||
|
def start_mesh():
|
||||||
|
"""
|
||||||
|
Start Meshtastic listener.
|
||||||
|
|
||||||
|
Connects to a Meshtastic device and begins receiving messages.
|
||||||
|
The device must be connected via USB/Serial.
|
||||||
|
|
||||||
|
JSON body (optional):
|
||||||
|
{
|
||||||
|
"device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided.
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with connection status.
|
||||||
|
"""
|
||||||
|
if not is_meshtastic_available():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Meshtastic SDK not installed. Install with: pip install meshtastic'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
if client and client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'device': client.device_path
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clear queue and history
|
||||||
|
while not _mesh_queue.empty():
|
||||||
|
try:
|
||||||
|
_mesh_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
_recent_messages.clear()
|
||||||
|
|
||||||
|
# Get optional device path
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
device = data.get('device')
|
||||||
|
|
||||||
|
# Validate device path if provided
|
||||||
|
if device:
|
||||||
|
device = str(device).strip()
|
||||||
|
if not device:
|
||||||
|
device = None
|
||||||
|
|
||||||
|
# Start client
|
||||||
|
success = start_meshtastic(device=device, callback=_message_callback)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
node_info = client.get_node_info() if client else None
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'device': client.device_path if client else None,
|
||||||
|
'node_info': node_info.to_dict() if node_info else None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': client.error if client else 'Failed to connect to Meshtastic device'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_mesh():
|
||||||
|
"""
|
||||||
|
Stop Meshtastic listener.
|
||||||
|
|
||||||
|
Disconnects from the Meshtastic device and stops receiving messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
stop_meshtastic()
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/channels')
|
||||||
|
def get_channels():
|
||||||
|
"""
|
||||||
|
Get configured channels on the connected device.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of channel configurations.
|
||||||
|
Note: PSK values are not returned for security - only encryption status.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
channels = client.get_channels()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'channels': [ch.to_dict() for ch in channels],
|
||||||
|
'count': len(channels)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/channels/<int:index>', methods=['POST'])
|
||||||
|
def configure_channel(index: int):
|
||||||
|
"""
|
||||||
|
Configure a channel with name and/or encryption key.
|
||||||
|
|
||||||
|
This allows joining encrypted channels by providing the PSK.
|
||||||
|
The configuration is written to the connected Meshtastic device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Channel index (0-7). Channel 0 is typically the primary channel.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"name": "MyChannel", // Optional: Channel name
|
||||||
|
"psk": "base64:ABC123..." // Optional: Encryption key
|
||||||
|
}
|
||||||
|
|
||||||
|
PSK formats:
|
||||||
|
- "none" : Disable encryption
|
||||||
|
- "default" : Use default public key (NOT SECURE - known key)
|
||||||
|
- "random" : Generate new random AES-256 key
|
||||||
|
- "base64:..." : Base64-encoded 16-byte (AES-128) or 32-byte (AES-256) key
|
||||||
|
- "0x..." : Hex-encoded key
|
||||||
|
- "simple:passphrase" : Derive AES-256 key from passphrase using SHA-256
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with configuration result.
|
||||||
|
|
||||||
|
Security note:
|
||||||
|
The "default" key is publicly known (shipped in source code).
|
||||||
|
Use "random" or provide your own key for secure communications.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if not 0 <= index <= 7:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Channel index must be 0-7'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
name = data.get('name')
|
||||||
|
psk = data.get('psk')
|
||||||
|
|
||||||
|
if not name and not psk:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Must provide name and/or psk'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Sanitize name if provided
|
||||||
|
if name:
|
||||||
|
name = str(name).strip()[:12] # Meshtastic channel names max 12 chars
|
||||||
|
|
||||||
|
# Validate PSK format if provided
|
||||||
|
if psk:
|
||||||
|
psk = str(psk).strip()
|
||||||
|
|
||||||
|
success, message = client.set_channel(index, name=name, psk=psk)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Return updated channel info
|
||||||
|
channels = client.get_channels()
|
||||||
|
updated = next((ch for ch in channels if ch.index == index), None)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'message': message,
|
||||||
|
'channel': updated.to_dict() if updated else None
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/send', methods=['POST'])
|
||||||
|
def send_message():
|
||||||
|
"""
|
||||||
|
Send a text message to the mesh network.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"text": "Hello mesh!", // Required: message text (max 237 chars)
|
||||||
|
"channel": 0, // Optional: channel index (default 0)
|
||||||
|
"to": "!a1b2c3d4" // Optional: destination node (default broadcast)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with send status.
|
||||||
|
"""
|
||||||
|
if not is_meshtastic_available():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Meshtastic SDK not installed'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
text = data.get('text', '').strip()
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Message text is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if len(text) > 237:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Message too long (max 237 characters)'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
channel = data.get('channel', 0)
|
||||||
|
if not isinstance(channel, int) or not 0 <= channel <= 7:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Channel must be 0-7'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
destination = data.get('to')
|
||||||
|
|
||||||
|
logger.info(f"Sending message: text='{text[:50]}...', channel={channel}, to={destination}")
|
||||||
|
success, error = client.send_text(text, channel=channel, destination=destination)
|
||||||
|
logger.info(f"Send result: success={success}, error={error}")
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'sent'})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': error or 'Failed to send message'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/messages')
|
||||||
|
def get_messages():
|
||||||
|
"""
|
||||||
|
Get recent message history.
|
||||||
|
|
||||||
|
Returns the most recent messages received since the listener was started.
|
||||||
|
Limited to the last 500 messages.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
limit: Maximum number of messages to return (default: all)
|
||||||
|
channel: Filter by channel index (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with message list.
|
||||||
|
"""
|
||||||
|
limit = request.args.get('limit', type=int)
|
||||||
|
channel = request.args.get('channel', type=int)
|
||||||
|
|
||||||
|
messages = _recent_messages.copy()
|
||||||
|
|
||||||
|
# Filter by channel if specified
|
||||||
|
if channel is not None:
|
||||||
|
messages = [m for m in messages if m.get('channel') == channel]
|
||||||
|
|
||||||
|
# Apply limit
|
||||||
|
if limit and limit > 0:
|
||||||
|
messages = messages[-limit:]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'messages': messages,
|
||||||
|
'count': len(messages)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/stream')
|
||||||
|
def stream_messages():
|
||||||
|
"""
|
||||||
|
SSE stream of Meshtastic messages.
|
||||||
|
|
||||||
|
Provides real-time Server-Sent Events stream of incoming messages.
|
||||||
|
Connect to this endpoint with EventSource to receive live updates.
|
||||||
|
|
||||||
|
Event format:
|
||||||
|
data: {"type": "meshtastic", "from": "!a1b2c3d4", "message": "Hello", ...}
|
||||||
|
|
||||||
|
Keepalive events are sent every 30 seconds to maintain the connection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SSE stream (text/event-stream)
|
||||||
|
"""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = _mesh_queue.get(timeout=1)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/node')
|
||||||
|
def get_node():
|
||||||
|
"""
|
||||||
|
Get local node information.
|
||||||
|
|
||||||
|
Returns information about the connected Meshtastic device including
|
||||||
|
its ID, name, hardware model, and current position (if available).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with node information.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
node_info = client.get_node_info()
|
||||||
|
|
||||||
|
if node_info:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'node': node_info.to_dict()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to get node information'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/nodes')
|
||||||
|
def get_nodes():
|
||||||
|
"""
|
||||||
|
Get all tracked mesh nodes with their positions.
|
||||||
|
|
||||||
|
Returns all nodes that have been seen on the mesh network,
|
||||||
|
including their positions (if reported), battery levels, and signal info.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
with_position: If 'true', only return nodes with valid positions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of nodes.
|
||||||
|
"""
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Not connected to Meshtastic device',
|
||||||
|
'nodes': []
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
nodes = client.get_nodes()
|
||||||
|
nodes_list = [n.to_dict() for n in nodes]
|
||||||
|
|
||||||
|
# Filter to only nodes with positions if requested
|
||||||
|
with_position = request.args.get('with_position', '').lower() == 'true'
|
||||||
|
if with_position:
|
||||||
|
nodes_list = [n for n in nodes_list if n.get('has_position')]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'nodes': nodes_list,
|
||||||
|
'count': len(nodes_list),
|
||||||
|
'with_position_count': sum(1 for n in nodes_list if n.get('has_position'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
Offline mode routes - Asset management and settings for offline operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from utils.database import get_setting, set_setting
|
||||||
|
import os
|
||||||
|
|
||||||
|
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||||
|
|
||||||
|
# Default offline settings
|
||||||
|
OFFLINE_DEFAULTS = {
|
||||||
|
'offline.enabled': False,
|
||||||
|
'offline.assets_source': 'cdn',
|
||||||
|
'offline.fonts_source': 'cdn',
|
||||||
|
'offline.tile_provider': 'openstreetmap',
|
||||||
|
'offline.tile_server_url': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asset paths to check
|
||||||
|
ASSET_PATHS = {
|
||||||
|
'leaflet': [
|
||||||
|
'static/vendor/leaflet/leaflet.js',
|
||||||
|
'static/vendor/leaflet/leaflet.css'
|
||||||
|
],
|
||||||
|
'chartjs': [
|
||||||
|
'static/vendor/chartjs/chart.umd.min.js'
|
||||||
|
],
|
||||||
|
'inter': [
|
||||||
|
'static/vendor/fonts/Inter-Regular.woff2',
|
||||||
|
'static/vendor/fonts/Inter-Medium.woff2',
|
||||||
|
'static/vendor/fonts/Inter-SemiBold.woff2',
|
||||||
|
'static/vendor/fonts/Inter-Bold.woff2'
|
||||||
|
],
|
||||||
|
'jetbrains': [
|
||||||
|
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
|
||||||
|
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
|
||||||
|
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
|
||||||
|
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
|
||||||
|
],
|
||||||
|
'leaflet_images': [
|
||||||
|
'static/vendor/leaflet/images/marker-icon.png',
|
||||||
|
'static/vendor/leaflet/images/marker-icon-2x.png',
|
||||||
|
'static/vendor/leaflet/images/marker-shadow.png',
|
||||||
|
'static/vendor/leaflet/images/layers.png',
|
||||||
|
'static/vendor/leaflet/images/layers-2x.png'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_offline_settings():
|
||||||
|
"""Get all offline settings with defaults."""
|
||||||
|
settings = {}
|
||||||
|
for key, default in OFFLINE_DEFAULTS.items():
|
||||||
|
settings[key] = get_setting(key, default)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@offline_bp.route('/settings', methods=['GET'])
|
||||||
|
def get_settings():
|
||||||
|
"""Get current offline settings."""
|
||||||
|
settings = get_offline_settings()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'settings': settings
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@offline_bp.route('/settings', methods=['POST'])
|
||||||
|
def save_setting():
|
||||||
|
"""Save an offline setting."""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'key' not in data or 'value' not in data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400
|
||||||
|
|
||||||
|
key = data['key']
|
||||||
|
value = data['value']
|
||||||
|
|
||||||
|
# Validate key is an allowed setting
|
||||||
|
if key not in OFFLINE_DEFAULTS:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400
|
||||||
|
|
||||||
|
# Validate value type matches default
|
||||||
|
default_type = type(OFFLINE_DEFAULTS[key])
|
||||||
|
if not isinstance(value, default_type):
|
||||||
|
# Try to convert
|
||||||
|
try:
|
||||||
|
if default_type == bool:
|
||||||
|
value = str(value).lower() in ('true', '1', 'yes')
|
||||||
|
else:
|
||||||
|
value = default_type(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid value type for {key}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
set_setting(key, value)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'key': key,
|
||||||
|
'value': value
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@offline_bp.route('/status', methods=['GET'])
|
||||||
|
def get_status():
|
||||||
|
"""Check status of local assets."""
|
||||||
|
# Get the app root directory
|
||||||
|
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
all_available = True
|
||||||
|
|
||||||
|
for asset_name, paths in ASSET_PATHS.items():
|
||||||
|
available = True
|
||||||
|
missing = []
|
||||||
|
for path in paths:
|
||||||
|
full_path = os.path.join(app_root, path)
|
||||||
|
if not os.path.exists(full_path):
|
||||||
|
available = False
|
||||||
|
missing.append(path)
|
||||||
|
|
||||||
|
results[asset_name] = {
|
||||||
|
'available': available,
|
||||||
|
'missing': missing if not available else []
|
||||||
|
}
|
||||||
|
|
||||||
|
if not available:
|
||||||
|
all_available = False
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'all_available': all_available,
|
||||||
|
'assets': results,
|
||||||
|
'offline_enabled': get_setting('offline.enabled', False)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@offline_bp.route('/check-asset', methods=['GET'])
|
||||||
|
def check_asset():
|
||||||
|
"""Check if a specific asset file exists."""
|
||||||
|
path = request.args.get('path', '')
|
||||||
|
if not path:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400
|
||||||
|
|
||||||
|
# Security: only allow checking within static/vendor
|
||||||
|
if not path.startswith('/static/vendor/'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400
|
||||||
|
|
||||||
|
# Remove leading slash and construct full path
|
||||||
|
relative_path = path.lstrip('/')
|
||||||
|
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
full_path = os.path.join(app_root, relative_path)
|
||||||
|
|
||||||
|
exists = os.path.exists(full_path)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'path': path,
|
||||||
|
'exists': exists
|
||||||
|
})
|
||||||
@@ -944,7 +944,7 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
|
|||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]:
|
def _scan_rf_signals(sdr_device: int | None, duration: int = 30, stop_check: callable | None = None) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Scan for RF signals using SDR (rtl_power).
|
Scan for RF signals using SDR (rtl_power).
|
||||||
|
|
||||||
@@ -956,7 +956,16 @@ def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]:
|
|||||||
- 915 MHz: US ISM band
|
- 915 MHz: US ISM band
|
||||||
- 1.2 GHz: Video transmitters
|
- 1.2 GHz: Video transmitters
|
||||||
- 2.4 GHz: WiFi, Bluetooth, video transmitters
|
- 2.4 GHz: WiFi, Bluetooth, video transmitters
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sdr_device: SDR device index
|
||||||
|
duration: Scan duration per band
|
||||||
|
stop_check: Optional callable that returns True if scan should stop.
|
||||||
|
Defaults to checking module-level _sweep_running.
|
||||||
"""
|
"""
|
||||||
|
# Default stop check uses module-level _sweep_running
|
||||||
|
if stop_check is None:
|
||||||
|
stop_check = lambda: not _sweep_running
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -1021,7 +1030,7 @@ def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]:
|
|||||||
|
|
||||||
# Scan each band and look for strong signals
|
# Scan each band and look for strong signals
|
||||||
for start_freq, end_freq, bin_size, band_name in scan_bands:
|
for start_freq, end_freq, bin_size, band_name in scan_bands:
|
||||||
if not _sweep_running:
|
if stop_check():
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)")
|
logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)")
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ install_multimon_ng_from_source_macos() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=14
|
TOTAL_STEPS=15
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -478,6 +478,19 @@ install_macos_packages() {
|
|||||||
progress "Installing gpsd"
|
progress "Installing gpsd"
|
||||||
brew_install gpsd
|
brew_install gpsd
|
||||||
|
|
||||||
|
progress "Installing Ubertooth tools (optional)"
|
||||||
|
if ! cmd_exists ubertooth-btle; then
|
||||||
|
echo
|
||||||
|
info "Ubertooth is used for advanced Bluetooth packet sniffing with Ubertooth One hardware."
|
||||||
|
if ask_yes_no "Do you want to install Ubertooth tools?"; then
|
||||||
|
brew_install ubertooth || warn "Ubertooth not available via Homebrew"
|
||||||
|
else
|
||||||
|
warn "Skipping Ubertooth installation. You can install it later if needed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "Ubertooth already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
||||||
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
|
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
|
||||||
echo
|
echo
|
||||||
@@ -613,6 +626,34 @@ install_aiscatcher_from_source_debian() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_ubertooth_from_source_debian() {
|
||||||
|
info "Building Ubertooth from source..."
|
||||||
|
|
||||||
|
apt_install build-essential git cmake libusb-1.0-0-dev pkg-config libbluetooth-dev
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning Ubertooth..."
|
||||||
|
git clone --depth 1 https://github.com/greatscottgadgets/ubertooth.git "$tmp_dir/ubertooth" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone Ubertooth"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/ubertooth/host"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling Ubertooth..."
|
||||||
|
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
|
$SUDO ldconfig
|
||||||
|
ok "Ubertooth installed successfully from source."
|
||||||
|
else
|
||||||
|
warn "Failed to build Ubertooth from source."
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
install_rtlsdr_blog_drivers_debian() {
|
install_rtlsdr_blog_drivers_debian() {
|
||||||
# The RTL-SDR Blog drivers provide better support for:
|
# The RTL-SDR Blog drivers provide better support for:
|
||||||
# - RTL-SDR Blog V4 (R828D tuner)
|
# - RTL-SDR Blog V4 (R828D tuner)
|
||||||
@@ -720,7 +761,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=19
|
TOTAL_STEPS=20
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -818,6 +859,19 @@ install_debian_packages() {
|
|||||||
progress "Installing Bluetooth tools"
|
progress "Installing Bluetooth tools"
|
||||||
apt_install bluez bluetooth || true
|
apt_install bluez bluetooth || true
|
||||||
|
|
||||||
|
progress "Installing Ubertooth tools (optional)"
|
||||||
|
if ! cmd_exists ubertooth-btle; then
|
||||||
|
echo
|
||||||
|
info "Ubertooth is used for advanced Bluetooth packet sniffing with Ubertooth One hardware."
|
||||||
|
if ask_yes_no "Do you want to install Ubertooth tools?"; then
|
||||||
|
apt_install libubertooth-dev ubertooth || install_ubertooth_from_source_debian
|
||||||
|
else
|
||||||
|
warn "Skipping Ubertooth installation. You can install it later if needed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "Ubertooth already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing SoapySDR"
|
progress "Installing SoapySDR"
|
||||||
# Exclude xtrx-dkms - its kernel module fails to build on newer kernels (6.14+)
|
# Exclude xtrx-dkms - its kernel module fails to build on newer kernels (6.14+)
|
||||||
# and causes apt to hang. Most users don't have XTRX hardware anyway.
|
# and causes apt to hang. Most users don't have XTRX hardware anyway.
|
||||||
|
|||||||
@@ -1710,6 +1710,12 @@ body {
|
|||||||
box-shadow: 0 0 10px var(--accent-red);
|
box-shadow: 0 0 10px var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.strip-status .status-dot.warn {
|
||||||
|
background: var(--accent-yellow, #ffcc00);
|
||||||
|
box-shadow: 0 0 10px var(--accent-yellow, #ffcc00);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.strip-time {
|
.strip-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
/*
|
||||||
|
* Agents Management CSS
|
||||||
|
* Styles for the remote agent management interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* CSS Variables (inherited from main theme) */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--text-secondary: #888;
|
||||||
|
--border-color: #1a1a2e;
|
||||||
|
--accent-cyan: #00d4ff;
|
||||||
|
--accent-green: #00ff88;
|
||||||
|
--accent-red: #ff3366;
|
||||||
|
--accent-orange: #ff9f1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent indicator in navigation */
|
||||||
|
.agent-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-dot.remote {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 6px var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-dot.multiple {
|
||||||
|
background: var(--accent-orange);
|
||||||
|
box-shadow: 0 0 6px var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-count {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent selector dropdown */
|
||||||
|
.agent-selector {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-width: 280px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-dropdown.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-manage {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-manage:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item.selected {
|
||||||
|
background: rgba(0, 212, 255, 0.15);
|
||||||
|
border-left: 3px solid var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item.local {
|
||||||
|
border-left: 3px solid var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-status.online {
|
||||||
|
background: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-status.offline {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-url {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item-check {
|
||||||
|
color: var(--accent-green);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-item.selected .agent-selector-item-check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent badge in data displays */
|
||||||
|
.agent-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-badge.local,
|
||||||
|
.agent-badge.agent-local {
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-badge.agent-remote {
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WiFi table agent column */
|
||||||
|
.wifi-networks-table .col-agent {
|
||||||
|
width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-networks-table th.col-agent {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bluetooth table agent column */
|
||||||
|
.bt-devices-table .col-agent {
|
||||||
|
width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-badge-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent column in data tables */
|
||||||
|
.data-table .agent-col {
|
||||||
|
width: 120px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-agent stream indicator */
|
||||||
|
.multi-agent-indicator {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-agent-indicator.active {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-agent-indicator-pulse {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent connection status toast */
|
||||||
|
.agent-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 1001;
|
||||||
|
animation: slideInRight 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-toast.connected {
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-toast.disconnected {
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.agent-indicator {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-selector-dropdown {
|
||||||
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -495,9 +495,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.no-vessel-icon {
|
.no-vessel-icon {
|
||||||
font-size: 36px;
|
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
opacity: 0.5;
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vessel-header {
|
.vessel-header {
|
||||||
@@ -508,7 +507,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-icon {
|
.vessel-icon {
|
||||||
font-size: 32px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vessel-name {
|
.vessel-name {
|
||||||
@@ -595,7 +596,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-icon {
|
.vessel-item-icon {
|
||||||
font-size: 20px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-info {
|
.vessel-item-info {
|
||||||
@@ -747,19 +751,12 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vessel-marker-inner {
|
.vessel-marker svg {
|
||||||
width: 24px;
|
transition: filter 0.2s ease;
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 18px;
|
|
||||||
filter: drop-shadow(0 0 2px rgba(0,0,0,0.8));
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vessel-marker.selected .vessel-marker-inner {
|
.vessel-marker.selected svg {
|
||||||
filter: drop-shadow(0 0 6px var(--accent-cyan));
|
filter: drop-shadow(0 0 8px rgba(255,255,255,0.8)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Range rings */
|
/* Range rings */
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/* Local font declarations for offline mode */
|
||||||
|
|
||||||
|
/* Inter - Primary UI font */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono - Monospace/code font */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
@@ -113,6 +113,77 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Spinning Globe Background */
|
||||||
|
.globe-background {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(80vh, 80vw);
|
||||||
|
height: min(80vh, 80vw);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
opacity: 0.06;
|
||||||
|
animation: globeSpin 60s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .globe-svg {
|
||||||
|
opacity: 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes globeSpin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-svg .rotating-meridians {
|
||||||
|
transform-origin: center;
|
||||||
|
animation: meridianSpin 45s linear infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes meridianSpin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-svg .meridian-1 {
|
||||||
|
transform-origin: center;
|
||||||
|
animation: meridianPulse 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-svg .meridian-2 {
|
||||||
|
transform-origin: center;
|
||||||
|
animation: meridianPulse 8s ease-in-out infinite 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-svg .meridian-3 {
|
||||||
|
transform-origin: center;
|
||||||
|
animation: meridianPulse 8s ease-in-out infinite 4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes meridianPulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.welcome-container {
|
.welcome-container {
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
@@ -301,7 +372,18 @@ body {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 16px;
|
||||||
|
max-height: calc(100vh - 300px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modes > h2 {
|
||||||
|
position: sticky;
|
||||||
|
top: -16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: -8px 0 12px 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-grid {
|
.mode-grid {
|
||||||
@@ -368,6 +450,65 @@ body {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mode Categories */
|
||||||
|
.mode-category {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-category:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-category-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-category-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-category-icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact Mode Grid */
|
||||||
|
.mode-grid-compact {
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card-sm {
|
||||||
|
padding: 10px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card-sm .mode-icon {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card-sm .mode-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card-sm .mode-name {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Welcome Footer */
|
/* Welcome Footer */
|
||||||
.welcome-footer {
|
.welcome-footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -430,11 +571,18 @@ body {
|
|||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Larger phones: 3 columns for mode grid */
|
.mode-grid-compact {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger phones: more columns for mode grid */
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
.mode-grid {
|
.mode-grid {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
|
.mode-grid-compact {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet and up: Side-by-side layout */
|
/* Tablet and up: Side-by-side layout */
|
||||||
@@ -451,6 +599,10 @@ body {
|
|||||||
.welcome-title-block {
|
.welcome-title-block {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-grid-compact {
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -1303,7 +1455,7 @@ header h1 .tagline {
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -1840,6 +1992,27 @@ header h1 .tagline {
|
|||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent status indicator */
|
||||||
|
.agent-status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status-dot.online {
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status-dot.offline {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status-dot.unknown {
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -6029,4 +6202,50 @@ body::before {
|
|||||||
|
|
||||||
.preset-freq-btn:active {
|
.preset-freq-btn:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation toggle icon states in nav bar */
|
||||||
|
.nav-tool-btn .icon-effects-on,
|
||||||
|
.nav-tool-btn .icon-effects-off {
|
||||||
|
position: absolute;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-effects-on {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tool-btn .icon-effects-off {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-on {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .nav-tool-btn .icon-effects-off {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable cosmetic animations when toggled off */
|
||||||
|
[data-animations="off"] .globe-svg,
|
||||||
|
[data-animations="off"] .rotating-meridians,
|
||||||
|
[data-animations="off"] .meridian-1,
|
||||||
|
[data-animations="off"] .meridian-2,
|
||||||
|
[data-animations="off"] .meridian-3,
|
||||||
|
[data-animations="off"] .welcome-scanline,
|
||||||
|
[data-animations="off"] .landing-scanline,
|
||||||
|
[data-animations="off"] .scanline,
|
||||||
|
[data-animations="off"] .signal-wave,
|
||||||
|
[data-animations="off"] .signal-wave-1,
|
||||||
|
[data-animations="off"] .signal-wave-2,
|
||||||
|
[data-animations="off"] .signal-wave-3,
|
||||||
|
[data-animations="off"] .logo-dot,
|
||||||
|
[data-animations="off"] .welcome-logo {
|
||||||
|
animation: none !important;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
/* Settings Modal Styles */
|
||||||
|
|
||||||
|
.settings-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
z-index: 10000;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal.active {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
background: var(--bg-dark, #0a0a0f);
|
||||||
|
border: 1px solid var(--border-color, #1a1a2e);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 .icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-close:hover {
|
||||||
|
color: var(--accent-red, #ff4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Tabs */
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1a1a2e);
|
||||||
|
padding: 0 20px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab:hover {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Sections */
|
||||||
|
.settings-section {
|
||||||
|
display: none;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Row */
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--bg-tertiary, #1a1a2e);
|
||||||
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background-color: var(--text-muted, #666);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-slider {
|
||||||
|
background-color: var(--accent-cyan, #00d4ff);
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:focus + .toggle-slider {
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select Dropdown */
|
||||||
|
.settings-select {
|
||||||
|
background: var(--bg-tertiary, #1a1a2e);
|
||||||
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
min-width: 160px;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
padding-right: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Input */
|
||||||
|
.settings-input {
|
||||||
|
background: var(--bg-tertiary, #1a1a2e);
|
||||||
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input::placeholder {
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Asset Status */
|
||||||
|
.asset-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-secondary, #0f0f1a);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-status-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-name {
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-badge.available {
|
||||||
|
background: rgba(0, 255, 136, 0.15);
|
||||||
|
color: var(--accent-green, #00ff88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-badge.missing {
|
||||||
|
background: rgba(255, 68, 68, 0.15);
|
||||||
|
color: var(--accent-red, #ff4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-badge.checking {
|
||||||
|
background: rgba(255, 170, 0, 0.15);
|
||||||
|
color: var(--accent-orange, #ffaa00);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Check Assets Button */
|
||||||
|
.check-assets-btn {
|
||||||
|
background: var(--bg-tertiary, #1a1a2e);
|
||||||
|
border: 1px solid var(--border-color, #2a2a3e);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 12px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-assets-btn:hover {
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-assets-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* About Section */
|
||||||
|
.about-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info a {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tile Provider Custom URL */
|
||||||
|
.custom-url-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-url-row .settings-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Callout */
|
||||||
|
.settings-info {
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-info strong {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-modal.active {
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-select,
|
||||||
|
.settings-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -988,6 +988,66 @@ const SignalCards = (function() {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTML for all meter detail fields from raw message data
|
||||||
|
*/
|
||||||
|
function buildMeterDetailsHtml(msg, seenCount) {
|
||||||
|
let html = '';
|
||||||
|
const rawMessage = msg.rawMessage || {};
|
||||||
|
|
||||||
|
// Display all fields from the raw rtlamr message
|
||||||
|
for (const [key, value] of Object.entries(rawMessage)) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
|
||||||
|
// Format the label (convert camelCase/PascalCase to spaces)
|
||||||
|
const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim();
|
||||||
|
|
||||||
|
// Format the value based on type
|
||||||
|
let displayValue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// For arrays like DifferentialConsumptionIntervals, show count and values
|
||||||
|
if (value.length > 10) {
|
||||||
|
displayValue = `[${value.length} values] ${value.slice(0, 5).join(', ')}...`;
|
||||||
|
} else {
|
||||||
|
displayValue = value.join(', ');
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
displayValue = JSON.stringify(value);
|
||||||
|
} else if (key === 'Consumption') {
|
||||||
|
displayValue = `${value.toLocaleString()} units`;
|
||||||
|
} else {
|
||||||
|
displayValue = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">${escapeHtml(label)}</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(displayValue)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message type if not in raw message
|
||||||
|
if (!rawMessage.Type && msg.type) {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Message Type</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(msg.type)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add seen count
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Seen</span>
|
||||||
|
<span class="signal-advanced-value">${seenCount} time${seenCount > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a utility meter (rtlamr) card
|
* Create a utility meter (rtlamr) card
|
||||||
*/
|
*/
|
||||||
@@ -1060,30 +1120,7 @@ const SignalCards = (function() {
|
|||||||
<div class="signal-advanced-section">
|
<div class="signal-advanced-section">
|
||||||
<div class="signal-advanced-title">Meter Details</div>
|
<div class="signal-advanced-title">Meter Details</div>
|
||||||
<div class="signal-advanced-grid">
|
<div class="signal-advanced-grid">
|
||||||
<div class="signal-advanced-item">
|
${buildMeterDetailsHtml(msg, seenCount)}
|
||||||
<span class="signal-advanced-label">Meter ID</span>
|
|
||||||
<span class="signal-advanced-value">${escapeHtml(msg.id || 'N/A')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="signal-advanced-item">
|
|
||||||
<span class="signal-advanced-label">Type</span>
|
|
||||||
<span class="signal-advanced-value">${escapeHtml(msg.type || 'Unknown')}</span>
|
|
||||||
</div>
|
|
||||||
${msg.endpoint_type ? `
|
|
||||||
<div class="signal-advanced-item">
|
|
||||||
<span class="signal-advanced-label">Endpoint</span>
|
|
||||||
<span class="signal-advanced-value">${escapeHtml(msg.endpoint_type)}</span>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${msg.endpoint_id ? `
|
|
||||||
<div class="signal-advanced-item">
|
|
||||||
<span class="signal-advanced-label">Endpoint ID</span>
|
|
||||||
<span class="signal-advanced-value">${escapeHtml(msg.endpoint_id)}</span>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
<div class="signal-advanced-item">
|
|
||||||
<span class="signal-advanced-label">Seen</span>
|
|
||||||
<span class="signal-advanced-value">${seenCount} time${seenCount > 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function switchMode(mode) {
|
|||||||
const modeMap = {
|
const modeMap = {
|
||||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||||
'listening': 'listening'
|
'listening': 'listening', 'meshtastic': 'meshtastic'
|
||||||
};
|
};
|
||||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||||
const label = btn.querySelector('.nav-label');
|
const label = btn.querySelector('.nav-label');
|
||||||
@@ -107,11 +107,16 @@ function switchMode(mode) {
|
|||||||
// Toggle mode content visibility
|
// Toggle mode content visibility
|
||||||
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
|
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
|
||||||
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
|
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
|
||||||
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
|
document.getElementById('aircraftMode')?.classList.toggle('active', mode === 'aircraft');
|
||||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||||
|
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||||
|
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||||
|
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||||
|
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||||
|
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||||
|
|
||||||
// Toggle stats visibility
|
// Toggle stats visibility
|
||||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||||||
@@ -137,7 +142,8 @@ function switchMode(mode) {
|
|||||||
'bluetooth': 'BLUETOOTH',
|
'bluetooth': 'BLUETOOTH',
|
||||||
'listening': 'LISTENING POST',
|
'listening': 'LISTENING POST',
|
||||||
'tscm': 'TSCM',
|
'tscm': 'TSCM',
|
||||||
'aprs': 'APRS'
|
'aprs': 'APRS',
|
||||||
|
'meshtastic': 'MESHTASTIC'
|
||||||
};
|
};
|
||||||
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
||||||
|
|
||||||
@@ -167,7 +173,8 @@ function switchMode(mode) {
|
|||||||
'satellite': 'Satellite Monitor',
|
'satellite': 'Satellite Monitor',
|
||||||
'wifi': 'WiFi Scanner',
|
'wifi': 'WiFi Scanner',
|
||||||
'bluetooth': 'Bluetooth Scanner',
|
'bluetooth': 'Bluetooth Scanner',
|
||||||
'listening': 'Listening Post'
|
'listening': 'Listening Post',
|
||||||
|
'meshtastic': 'Meshtastic Mesh Monitor'
|
||||||
};
|
};
|
||||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||||
|
|
||||||
@@ -197,10 +204,10 @@ function switchMode(mode) {
|
|||||||
|
|
||||||
// Hide waterfall and output console for modes with their own visualizations
|
// Hide waterfall and output console for modes with their own visualizations
|
||||||
document.querySelector('.waterfall-container').style.display =
|
document.querySelector('.waterfall-container').style.display =
|
||||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||||
document.getElementById('output').style.display =
|
document.getElementById('output').style.display =
|
||||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm') ? 'none' : 'flex';
|
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||||
|
|
||||||
// Load interfaces and initialize visualizations when switching modes
|
// Load interfaces and initialize visualizations when switching modes
|
||||||
if (mode === 'wifi') {
|
if (mode === 'wifi') {
|
||||||
@@ -221,6 +228,8 @@ function switchMode(mode) {
|
|||||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
if (typeof checkAudioTools === 'function') checkAudioTools();
|
||||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
||||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
||||||
|
} else if (mode === 'meshtastic') {
|
||||||
|
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
/**
|
||||||
|
* Settings Manager - Handles offline mode and application settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Settings = {
|
||||||
|
// Default settings
|
||||||
|
defaults: {
|
||||||
|
'offline.enabled': false,
|
||||||
|
'offline.assets_source': 'cdn',
|
||||||
|
'offline.fonts_source': 'cdn',
|
||||||
|
'offline.tile_provider': 'openstreetmap',
|
||||||
|
'offline.tile_server_url': ''
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tile provider configurations
|
||||||
|
tileProviders: {
|
||||||
|
openstreetmap: {
|
||||||
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
subdomains: 'abc'
|
||||||
|
},
|
||||||
|
cartodb_dark: {
|
||||||
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
subdomains: 'abcd'
|
||||||
|
},
|
||||||
|
cartodb_light: {
|
||||||
|
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
subdomains: 'abcd'
|
||||||
|
},
|
||||||
|
esri_world: {
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
||||||
|
subdomains: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Current settings cache
|
||||||
|
_cache: {},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize settings - load from server/localStorage
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/offline/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
this._cache = { ...this.defaults, ...data.settings };
|
||||||
|
} else {
|
||||||
|
// Fall back to localStorage
|
||||||
|
this._loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load settings from server, using localStorage:', e);
|
||||||
|
this._loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._updateUI();
|
||||||
|
return this._cache;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load settings from localStorage
|
||||||
|
*/
|
||||||
|
_loadFromLocalStorage() {
|
||||||
|
const stored = localStorage.getItem('intercept_settings');
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
this._cache = { ...this.defaults, ...JSON.parse(stored) };
|
||||||
|
} catch (e) {
|
||||||
|
this._cache = { ...this.defaults };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._cache = { ...this.defaults };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a setting to server and localStorage
|
||||||
|
*/
|
||||||
|
async _save(key, value) {
|
||||||
|
this._cache[key] = value;
|
||||||
|
|
||||||
|
// Save to localStorage as backup
|
||||||
|
localStorage.setItem('intercept_settings', JSON.stringify(this._cache));
|
||||||
|
|
||||||
|
// Save to server
|
||||||
|
try {
|
||||||
|
await fetch('/offline/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to save setting to server:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a setting value
|
||||||
|
*/
|
||||||
|
get(key) {
|
||||||
|
return this._cache[key] ?? this.defaults[key];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle offline mode master switch
|
||||||
|
*/
|
||||||
|
async toggleOfflineMode(enabled) {
|
||||||
|
await this._save('offline.enabled', enabled);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// When enabling offline mode, also switch assets and fonts to local
|
||||||
|
await this._save('offline.assets_source', 'local');
|
||||||
|
await this._save('offline.fonts_source', 'local');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._updateUI();
|
||||||
|
this._showReloadPrompt();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set asset source (cdn or local)
|
||||||
|
*/
|
||||||
|
async setAssetSource(source) {
|
||||||
|
await this._save('offline.assets_source', source);
|
||||||
|
this._showReloadPrompt();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set fonts source (cdn or local)
|
||||||
|
*/
|
||||||
|
async setFontsSource(source) {
|
||||||
|
await this._save('offline.fonts_source', source);
|
||||||
|
this._showReloadPrompt();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set tile provider
|
||||||
|
*/
|
||||||
|
async setTileProvider(provider) {
|
||||||
|
await this._save('offline.tile_provider', provider);
|
||||||
|
|
||||||
|
// Show/hide custom URL input
|
||||||
|
const customRow = document.getElementById('customTileUrlRow');
|
||||||
|
if (customRow) {
|
||||||
|
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not custom and we have a map, update tiles immediately
|
||||||
|
if (provider !== 'custom') {
|
||||||
|
this._updateMapTiles();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set custom tile server URL
|
||||||
|
*/
|
||||||
|
async setCustomTileUrl(url) {
|
||||||
|
await this._save('offline.tile_server_url', url);
|
||||||
|
this._updateMapTiles();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current tile configuration
|
||||||
|
*/
|
||||||
|
getTileConfig() {
|
||||||
|
const provider = this.get('offline.tile_provider');
|
||||||
|
|
||||||
|
if (provider === 'custom') {
|
||||||
|
const customUrl = this.get('offline.tile_server_url');
|
||||||
|
return {
|
||||||
|
url: customUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution: 'Custom Tile Server',
|
||||||
|
subdomains: 'abc'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tileProviders[provider] || this.tileProviders.openstreetmap;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if local assets are available
|
||||||
|
*/
|
||||||
|
async checkAssets() {
|
||||||
|
const assets = {
|
||||||
|
leaflet: [
|
||||||
|
'/static/vendor/leaflet/leaflet.js',
|
||||||
|
'/static/vendor/leaflet/leaflet.css'
|
||||||
|
],
|
||||||
|
chartjs: [
|
||||||
|
'/static/vendor/chartjs/chart.umd.min.js'
|
||||||
|
],
|
||||||
|
inter: [
|
||||||
|
'/static/vendor/fonts/Inter-Regular.woff2'
|
||||||
|
],
|
||||||
|
jetbrains: [
|
||||||
|
'/static/vendor/fonts/JetBrainsMono-Regular.woff2'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
for (const [name, urls] of Object.entries(assets)) {
|
||||||
|
const statusEl = document.getElementById(`status${name.charAt(0).toUpperCase() + name.slice(1)}`);
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = 'Checking...';
|
||||||
|
statusEl.className = 'asset-badge checking';
|
||||||
|
}
|
||||||
|
|
||||||
|
let available = true;
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method: 'HEAD' });
|
||||||
|
if (!response.ok) {
|
||||||
|
available = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
available = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[name] = available;
|
||||||
|
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = available ? 'Available' : 'Missing';
|
||||||
|
statusEl.className = `asset-badge ${available ? 'available' : 'missing'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update UI elements to reflect current settings
|
||||||
|
*/
|
||||||
|
_updateUI() {
|
||||||
|
// Offline mode toggle
|
||||||
|
const offlineEnabled = document.getElementById('offlineEnabled');
|
||||||
|
if (offlineEnabled) {
|
||||||
|
offlineEnabled.checked = this.get('offline.enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets source
|
||||||
|
const assetsSource = document.getElementById('assetsSource');
|
||||||
|
if (assetsSource) {
|
||||||
|
assetsSource.value = this.get('offline.assets_source');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonts source
|
||||||
|
const fontsSource = document.getElementById('fontsSource');
|
||||||
|
if (fontsSource) {
|
||||||
|
fontsSource.value = this.get('offline.fonts_source');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tile provider
|
||||||
|
const tileProvider = document.getElementById('tileProvider');
|
||||||
|
if (tileProvider) {
|
||||||
|
tileProvider.value = this.get('offline.tile_provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom tile URL
|
||||||
|
const customTileUrl = document.getElementById('customTileUrl');
|
||||||
|
if (customTileUrl) {
|
||||||
|
customTileUrl.value = this.get('offline.tile_server_url') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide custom URL row
|
||||||
|
const customRow = document.getElementById('customTileUrlRow');
|
||||||
|
if (customRow) {
|
||||||
|
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update map tiles if a map exists
|
||||||
|
*/
|
||||||
|
_updateMapTiles() {
|
||||||
|
// Look for common map variable names
|
||||||
|
const maps = [
|
||||||
|
window.map,
|
||||||
|
window.leafletMap,
|
||||||
|
window.aprsMap,
|
||||||
|
window.adsbMap
|
||||||
|
].filter(m => m && typeof m.eachLayer === 'function');
|
||||||
|
|
||||||
|
if (maps.length === 0) return;
|
||||||
|
|
||||||
|
const config = this.getTileConfig();
|
||||||
|
|
||||||
|
maps.forEach(map => {
|
||||||
|
// Remove existing tile layers
|
||||||
|
map.eachLayer(layer => {
|
||||||
|
if (layer instanceof L.TileLayer) {
|
||||||
|
map.removeLayer(layer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new tile layer
|
||||||
|
const options = {
|
||||||
|
attribution: config.attribution
|
||||||
|
};
|
||||||
|
if (config.subdomains) {
|
||||||
|
options.subdomains = config.subdomains;
|
||||||
|
}
|
||||||
|
|
||||||
|
L.tileLayer(config.url, options).addTo(map);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show reload prompt
|
||||||
|
*/
|
||||||
|
_showReloadPrompt() {
|
||||||
|
// Create or update reload prompt
|
||||||
|
let prompt = document.getElementById('settingsReloadPrompt');
|
||||||
|
if (!prompt) {
|
||||||
|
prompt = document.createElement('div');
|
||||||
|
prompt.id = 'settingsReloadPrompt';
|
||||||
|
prompt.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: var(--bg-dark, #0a0a0f);
|
||||||
|
border: 1px solid var(--accent-cyan, #00d4ff);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 10001;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
`;
|
||||||
|
prompt.innerHTML = `
|
||||||
|
<span style="color: var(--text-primary, #e0e0e0); font-size: 13px;">
|
||||||
|
Reload to apply changes
|
||||||
|
</span>
|
||||||
|
<button onclick="location.reload()" style="
|
||||||
|
background: var(--accent-cyan, #00d4ff);
|
||||||
|
border: none;
|
||||||
|
color: #000;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
">Reload</button>
|
||||||
|
<button onclick="this.parentElement.remove()" style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
">×</button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Settings modal functions
|
||||||
|
function showSettings() {
|
||||||
|
const modal = document.getElementById('settingsModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('active');
|
||||||
|
Settings.init().then(() => {
|
||||||
|
Settings.checkAssets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSettings() {
|
||||||
|
const modal = document.getElementById('settingsModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchSettingsTab(tabName) {
|
||||||
|
// Update tab buttons
|
||||||
|
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||||
|
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update sections
|
||||||
|
document.querySelectorAll('.settings-section').forEach(section => {
|
||||||
|
section.classList.toggle('active', section.id === `settings-${tabName}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize settings on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
Settings.init();
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ const BluetoothMode = (function() {
|
|||||||
// State
|
// State
|
||||||
let isScanning = false;
|
let isScanning = false;
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
|
let agentPollTimer = null; // Polling fallback for agent mode
|
||||||
let devices = new Map();
|
let devices = new Map();
|
||||||
let baselineSet = false;
|
let baselineSet = false;
|
||||||
let baselineCount = 0;
|
let baselineCount = 0;
|
||||||
@@ -36,6 +37,47 @@ const BluetoothMode = (function() {
|
|||||||
// Device list filter
|
// Device list filter
|
||||||
let currentDeviceFilter = 'all';
|
let currentDeviceFilter = 'all';
|
||||||
|
|
||||||
|
// Agent support
|
||||||
|
let showAllAgentsMode = false;
|
||||||
|
let lastAgentId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get API base URL, routing through agent proxy if agent is selected.
|
||||||
|
*/
|
||||||
|
function getApiBase() {
|
||||||
|
if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') {
|
||||||
|
return `/controller/agents/${currentAgent}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current agent name for tagging data.
|
||||||
|
*/
|
||||||
|
function getCurrentAgentName() {
|
||||||
|
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||||
|
return 'Local';
|
||||||
|
}
|
||||||
|
if (typeof agents !== 'undefined') {
|
||||||
|
const agent = agents.find(a => a.id == currentAgent);
|
||||||
|
return agent ? agent.name : `Agent ${currentAgent}`;
|
||||||
|
}
|
||||||
|
return `Agent ${currentAgent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for agent mode conflicts before starting scan.
|
||||||
|
*/
|
||||||
|
function checkAgentConflicts() {
|
||||||
|
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof checkAgentModeConflict === 'function') {
|
||||||
|
return checkAgentModeConflict('bluetooth');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Bluetooth mode
|
* Initialize the Bluetooth mode
|
||||||
*/
|
*/
|
||||||
@@ -526,8 +568,37 @@ const BluetoothMode = (function() {
|
|||||||
*/
|
*/
|
||||||
async function checkCapabilities() {
|
async function checkCapabilities() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/bluetooth/capabilities');
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
const data = await response.json();
|
let data;
|
||||||
|
|
||||||
|
if (isAgentMode) {
|
||||||
|
// Fetch capabilities from agent via controller proxy
|
||||||
|
const response = await fetch(`/controller/agents/${currentAgent}?refresh=true`);
|
||||||
|
const agentData = await response.json();
|
||||||
|
|
||||||
|
if (agentData.agent && agentData.agent.capabilities) {
|
||||||
|
const agentCaps = agentData.agent.capabilities;
|
||||||
|
const agentInterfaces = agentData.agent.interfaces || {};
|
||||||
|
|
||||||
|
// Build BT-compatible capabilities object
|
||||||
|
data = {
|
||||||
|
available: agentCaps.bluetooth || false,
|
||||||
|
adapters: (agentInterfaces.bt_adapters || []).map(adapter => ({
|
||||||
|
id: adapter.id || adapter.name || adapter,
|
||||||
|
name: adapter.name || adapter,
|
||||||
|
powered: adapter.powered !== false
|
||||||
|
})),
|
||||||
|
issues: [],
|
||||||
|
preferred_backend: 'auto'
|
||||||
|
};
|
||||||
|
console.log('[BT] Agent capabilities:', data);
|
||||||
|
} else {
|
||||||
|
data = { available: false, adapters: [], issues: ['Agent does not support Bluetooth'] };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await fetch('/api/bluetooth/capabilities');
|
||||||
|
data = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
if (!data.available) {
|
if (!data.available) {
|
||||||
showCapabilityWarning(['Bluetooth not available on this system']);
|
showCapabilityWarning(['Bluetooth not available on this system']);
|
||||||
@@ -579,10 +650,17 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
async function checkScanStatus() {
|
async function checkScanStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/bluetooth/scan/status');
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
const data = await response.json();
|
const endpoint = isAgentMode
|
||||||
|
? `/controller/agents/${currentAgent}/bluetooth/status`
|
||||||
|
: '/api/bluetooth/scan/status';
|
||||||
|
|
||||||
if (data.is_scanning) {
|
const response = await fetch(endpoint);
|
||||||
|
const responseData = await response.json();
|
||||||
|
// Handle agent response format (may be nested in 'result')
|
||||||
|
const data = isAgentMode && responseData.result ? responseData.result : responseData;
|
||||||
|
|
||||||
|
if (data.is_scanning || data.running) {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
startEventStream();
|
startEventStream();
|
||||||
}
|
}
|
||||||
@@ -599,32 +677,60 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startScan() {
|
async function startScan() {
|
||||||
|
// Check for agent mode conflicts
|
||||||
|
if (!checkAgentConflicts()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const adapter = adapterSelect?.value || '';
|
const adapter = adapterSelect?.value || '';
|
||||||
const mode = scanModeSelect?.value || 'auto';
|
const mode = scanModeSelect?.value || 'auto';
|
||||||
const transport = transportSelect?.value || 'auto';
|
const transport = transportSelect?.value || 'auto';
|
||||||
const duration = parseInt(durationInput?.value || '0', 10);
|
const duration = parseInt(durationInput?.value || '0', 10);
|
||||||
const minRssi = parseInt(minRssiInput?.value || '-100', 10);
|
const minRssi = parseInt(minRssiInput?.value || '-100', 10);
|
||||||
|
|
||||||
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/bluetooth/scan/start', {
|
let response;
|
||||||
method: 'POST',
|
if (isAgentMode) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// Route through agent proxy
|
||||||
body: JSON.stringify({
|
response = await fetch(`/controller/agents/${currentAgent}/bluetooth/start`, {
|
||||||
mode: mode,
|
method: 'POST',
|
||||||
adapter_id: adapter || undefined,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
duration_s: duration > 0 ? duration : undefined,
|
body: JSON.stringify({
|
||||||
transport: transport,
|
mode: mode,
|
||||||
rssi_threshold: minRssi
|
adapter_id: adapter || undefined,
|
||||||
})
|
duration_s: duration > 0 ? duration : undefined,
|
||||||
});
|
transport: transport,
|
||||||
|
rssi_threshold: minRssi
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch('/api/bluetooth/scan/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode: mode,
|
||||||
|
adapter_id: adapter || undefined,
|
||||||
|
duration_s: duration > 0 ? duration : undefined,
|
||||||
|
transport: transport,
|
||||||
|
rssi_threshold: minRssi
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === 'started' || data.status === 'already_scanning') {
|
// Handle controller proxy response format (agent response is nested in 'result')
|
||||||
|
const scanResult = isAgentMode && data.result ? data.result : data;
|
||||||
|
|
||||||
|
if (scanResult.status === 'started' || scanResult.status === 'already_scanning') {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
startEventStream();
|
startEventStream();
|
||||||
|
} else if (scanResult.status === 'error') {
|
||||||
|
showErrorMessage(scanResult.message || 'Failed to start scan');
|
||||||
} else {
|
} else {
|
||||||
showErrorMessage(data.message || 'Failed to start scan');
|
showErrorMessage(scanResult.message || 'Failed to start scan');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -634,8 +740,14 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function stopScan() {
|
async function stopScan() {
|
||||||
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
|
if (isAgentMode) {
|
||||||
|
await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { method: 'POST' });
|
||||||
|
} else {
|
||||||
|
await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
|
||||||
|
}
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
stopEventStream();
|
stopEventStream();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -680,27 +792,84 @@ const BluetoothMode = (function() {
|
|||||||
function startEventStream() {
|
function startEventStream() {
|
||||||
if (eventSource) eventSource.close();
|
if (eventSource) eventSource.close();
|
||||||
|
|
||||||
eventSource = new EventSource('/api/bluetooth/stream');
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
const agentName = getCurrentAgentName();
|
||||||
|
let streamUrl;
|
||||||
|
|
||||||
eventSource.addEventListener('device_update', (e) => {
|
if (isAgentMode) {
|
||||||
try {
|
// Use multi-agent stream for remote agents
|
||||||
const device = JSON.parse(e.data);
|
streamUrl = '/controller/stream/all';
|
||||||
handleDeviceUpdate(device);
|
console.log('[BT] Starting multi-agent event stream...');
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error('Failed to parse device update:', err);
|
streamUrl = '/api/bluetooth/stream';
|
||||||
}
|
console.log('[BT] Starting local event stream...');
|
||||||
});
|
}
|
||||||
|
|
||||||
eventSource.addEventListener('scan_started', (e) => {
|
eventSource = new EventSource(streamUrl);
|
||||||
setScanning(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener('scan_stopped', (e) => {
|
if (isAgentMode) {
|
||||||
setScanning(false);
|
// Handle multi-agent stream
|
||||||
});
|
eventSource.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
|
||||||
|
// Skip keepalive and non-bluetooth data
|
||||||
|
if (data.type === 'keepalive') return;
|
||||||
|
if (data.scan_type !== 'bluetooth') return;
|
||||||
|
|
||||||
|
// Filter by current agent if not in "show all" mode
|
||||||
|
if (!showAllAgentsMode && typeof agents !== 'undefined') {
|
||||||
|
const currentAgentObj = agents.find(a => a.id == currentAgent);
|
||||||
|
if (currentAgentObj && data.agent_name && data.agent_name !== currentAgentObj.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform multi-agent payload to device updates
|
||||||
|
if (data.payload && data.payload.devices) {
|
||||||
|
Object.values(data.payload.devices).forEach(device => {
|
||||||
|
device._agent = data.agent_name || 'Unknown';
|
||||||
|
handleDeviceUpdate(device);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse multi-agent event:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also start polling as fallback (in case push isn't enabled on agent)
|
||||||
|
startAgentPolling();
|
||||||
|
} else {
|
||||||
|
// Handle local stream
|
||||||
|
eventSource.addEventListener('device_update', (e) => {
|
||||||
|
try {
|
||||||
|
const device = JSON.parse(e.data);
|
||||||
|
device._agent = 'Local';
|
||||||
|
handleDeviceUpdate(device);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse device update:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('scan_started', (e) => {
|
||||||
|
setScanning(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('scan_stopped', (e) => {
|
||||||
|
setScanning(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
console.warn('Bluetooth SSE connection error');
|
console.warn('Bluetooth SSE connection error');
|
||||||
|
if (isScanning) {
|
||||||
|
// Attempt to reconnect
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isScanning) {
|
||||||
|
startEventStream();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,6 +878,54 @@ const BluetoothMode = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
if (agentPollTimer) {
|
||||||
|
clearInterval(agentPollTimer);
|
||||||
|
agentPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start polling agent data as fallback when push isn't enabled.
|
||||||
|
* This polls the controller proxy endpoint for agent data.
|
||||||
|
*/
|
||||||
|
function startAgentPolling() {
|
||||||
|
if (agentPollTimer) return;
|
||||||
|
|
||||||
|
const pollInterval = 3000; // 3 seconds
|
||||||
|
console.log('[BT] Starting agent polling fallback...');
|
||||||
|
|
||||||
|
agentPollTimer = setInterval(async () => {
|
||||||
|
if (!isScanning) {
|
||||||
|
clearInterval(agentPollTimer);
|
||||||
|
agentPollTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/controller/agents/${currentAgent}/bluetooth/data`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
|
// Process devices from polling response
|
||||||
|
if (data && data.devices) {
|
||||||
|
const agentName = getCurrentAgentName();
|
||||||
|
Object.values(data.devices).forEach(device => {
|
||||||
|
device._agent = agentName;
|
||||||
|
handleDeviceUpdate(device);
|
||||||
|
});
|
||||||
|
} else if (data && Array.isArray(data)) {
|
||||||
|
const agentName = getCurrentAgentName();
|
||||||
|
data.forEach(device => {
|
||||||
|
device._agent = agentName;
|
||||||
|
handleDeviceUpdate(device);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('[BT] Agent poll error:', err);
|
||||||
|
}
|
||||||
|
}, pollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeviceUpdate(device) {
|
function handleDeviceUpdate(device) {
|
||||||
@@ -876,6 +1093,7 @@ const BluetoothMode = (function() {
|
|||||||
const trackerType = device.tracker_type;
|
const trackerType = device.tracker_type;
|
||||||
const trackerConfidence = device.tracker_confidence;
|
const trackerConfidence = device.tracker_confidence;
|
||||||
const riskScore = device.risk_score || 0;
|
const riskScore = device.risk_score || 0;
|
||||||
|
const agentName = device._agent || 'Local';
|
||||||
|
|
||||||
// Calculate RSSI bar width (0-100%)
|
// Calculate RSSI bar width (0-100%)
|
||||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||||
@@ -929,6 +1147,10 @@ const BluetoothMode = (function() {
|
|||||||
let secondaryParts = [addr];
|
let secondaryParts = [addr];
|
||||||
if (mfr) secondaryParts.push(mfr);
|
if (mfr) secondaryParts.push(mfr);
|
||||||
secondaryParts.push('Seen ' + seenCount + '×');
|
secondaryParts.push('Seen ' + seenCount + '×');
|
||||||
|
// Add agent name if not Local
|
||||||
|
if (agentName !== 'Local') {
|
||||||
|
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
||||||
|
}
|
||||||
const secondaryInfo = secondaryParts.join(' · ');
|
const secondaryInfo = secondaryParts.join(' · ');
|
||||||
|
|
||||||
// Row border color - highlight trackers in red/orange
|
// Row border color - highlight trackers in red/orange
|
||||||
@@ -1019,6 +1241,112 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
function showErrorMessage(message) {
|
function showErrorMessage(message) {
|
||||||
console.error('[BT] Error:', message);
|
console.error('[BT] Error:', message);
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Bluetooth Error', message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInfo(message) {
|
||||||
|
console.log('[BT]', message);
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Bluetooth', message, 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Agent Handling
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle agent change - refresh adapters and optionally clear data.
|
||||||
|
*/
|
||||||
|
function handleAgentChange() {
|
||||||
|
const currentAgentId = typeof currentAgent !== 'undefined' ? currentAgent : 'local';
|
||||||
|
|
||||||
|
// Check if agent actually changed
|
||||||
|
if (lastAgentId === currentAgentId) return;
|
||||||
|
|
||||||
|
console.log('[BT] Agent changed from', lastAgentId, 'to', currentAgentId);
|
||||||
|
|
||||||
|
// Stop any running scan
|
||||||
|
if (isScanning) {
|
||||||
|
stopScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing data when switching agents (unless "Show All" is enabled)
|
||||||
|
if (!showAllAgentsMode) {
|
||||||
|
clearData();
|
||||||
|
showInfo(`Switched to ${getCurrentAgentName()} - previous data cleared`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh capabilities for new agent
|
||||||
|
checkCapabilities();
|
||||||
|
|
||||||
|
lastAgentId = currentAgentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all collected data.
|
||||||
|
*/
|
||||||
|
function clearData() {
|
||||||
|
devices.clear();
|
||||||
|
resetStats();
|
||||||
|
|
||||||
|
if (deviceContainer) {
|
||||||
|
deviceContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDeviceCount();
|
||||||
|
updateProximityZones();
|
||||||
|
updateRadar();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle "Show All Agents" mode.
|
||||||
|
*/
|
||||||
|
function toggleShowAllAgents(enabled) {
|
||||||
|
showAllAgentsMode = enabled;
|
||||||
|
console.log('[BT] Show all agents mode:', enabled);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// If currently scanning, switch to multi-agent stream
|
||||||
|
if (isScanning && eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
startEventStream();
|
||||||
|
}
|
||||||
|
showInfo('Showing Bluetooth devices from all agents');
|
||||||
|
} else {
|
||||||
|
// Filter to current agent only
|
||||||
|
filterToCurrentAgent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter devices to only show those from current agent.
|
||||||
|
*/
|
||||||
|
function filterToCurrentAgent() {
|
||||||
|
const agentName = getCurrentAgentName();
|
||||||
|
const toRemove = [];
|
||||||
|
|
||||||
|
devices.forEach((device, deviceId) => {
|
||||||
|
if (device._agent && device._agent !== agentName) {
|
||||||
|
toRemove.push(deviceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
toRemove.forEach(deviceId => devices.delete(deviceId));
|
||||||
|
|
||||||
|
// Re-render device list
|
||||||
|
if (deviceContainer) {
|
||||||
|
deviceContainer.innerHTML = '';
|
||||||
|
devices.forEach(device => renderDevice(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDeviceCount();
|
||||||
|
updateStatsFromDevices();
|
||||||
|
updateVisualizationPanels();
|
||||||
|
updateProximityZones();
|
||||||
|
updateRadar();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
@@ -1033,8 +1361,16 @@ const BluetoothMode = (function() {
|
|||||||
selectDevice,
|
selectDevice,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
copyAddress,
|
copyAddress,
|
||||||
|
|
||||||
|
// Agent handling
|
||||||
|
handleAgentChange,
|
||||||
|
clearData,
|
||||||
|
toggleShowAllAgents,
|
||||||
|
|
||||||
|
// Getters
|
||||||
getDevices: () => Array.from(devices.values()),
|
getDevices: () => Array.from(devices.values()),
|
||||||
isScanning: () => isScanning
|
isScanning: () => isScanning,
|
||||||
|
isShowAllAgents: () => showAllAgentsMode
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ let recentSignalHits = new Map();
|
|||||||
let isDirectListening = false;
|
let isDirectListening = false;
|
||||||
let currentModulation = 'am';
|
let currentModulation = 'am';
|
||||||
|
|
||||||
|
// Agent mode state
|
||||||
|
let listeningPostCurrentAgent = null;
|
||||||
|
let listeningPostPollTimer = null;
|
||||||
|
|
||||||
// ============== PRESETS ==============
|
// ============== PRESETS ==============
|
||||||
|
|
||||||
const scannerPresets = {
|
const scannerPresets = {
|
||||||
@@ -145,6 +149,13 @@ function startScanner() {
|
|||||||
const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10;
|
const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10;
|
||||||
const device = getSelectedDevice();
|
const device = getSelectedDevice();
|
||||||
|
|
||||||
|
// Check if using agent mode
|
||||||
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
listeningPostCurrentAgent = isAgentMode ? currentAgent : null;
|
||||||
|
|
||||||
|
// Disable listen button for agent mode (audio can't stream over HTTP)
|
||||||
|
updateListenButtonState(isAgentMode);
|
||||||
|
|
||||||
if (startFreq >= endFreq) {
|
if (startFreq >= endFreq) {
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('Scanner Error', 'End frequency must be greater than start');
|
showNotification('Scanner Error', 'End frequency must be greater than start');
|
||||||
@@ -152,8 +163,8 @@ function startScanner() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if device is available
|
// Check if device is available (only for local mode)
|
||||||
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) {
|
if (!isAgentMode && typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +192,12 @@ function startScanner() {
|
|||||||
document.getElementById('mainRangeEnd').textContent = endFreq.toFixed(1) + ' MHz';
|
document.getElementById('mainRangeEnd').textContent = endFreq.toFixed(1) + ' MHz';
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('/listening/scanner/start', {
|
// Determine endpoint based on agent mode
|
||||||
|
const endpoint = isAgentMode
|
||||||
|
? `/controller/agents/${currentAgent}/listening_post/start`
|
||||||
|
: '/listening/scanner/start';
|
||||||
|
|
||||||
|
fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -198,8 +214,11 @@ function startScanner() {
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'started') {
|
// Handle controller proxy response format
|
||||||
if (typeof reserveDevice === 'function') reserveDevice(device, 'scanner');
|
const scanResult = isAgentMode && data.result ? data.result : data;
|
||||||
|
|
||||||
|
if (scanResult.status === 'started' || scanResult.status === 'success') {
|
||||||
|
if (!isAgentMode && typeof reserveDevice === 'function') reserveDevice(device, 'scanner');
|
||||||
isScannerRunning = true;
|
isScannerRunning = true;
|
||||||
isScannerPaused = false;
|
isScannerPaused = false;
|
||||||
scannerSignalActive = false;
|
scannerSignalActive = false;
|
||||||
@@ -229,7 +248,7 @@ function startScanner() {
|
|||||||
const levelMeter = document.getElementById('scannerLevelMeter');
|
const levelMeter = document.getElementById('scannerLevelMeter');
|
||||||
if (levelMeter) levelMeter.style.display = 'block';
|
if (levelMeter) levelMeter.style.display = 'block';
|
||||||
|
|
||||||
connectScannerStream();
|
connectScannerStream(isAgentMode);
|
||||||
addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`);
|
addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`);
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`);
|
showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`);
|
||||||
@@ -237,7 +256,7 @@ function startScanner() {
|
|||||||
} else {
|
} else {
|
||||||
updateScannerDisplay('ERROR', 'var(--accent-red)');
|
updateScannerDisplay('ERROR', 'var(--accent-red)');
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('Scanner Error', data.message || 'Failed to start');
|
showNotification('Scanner Error', scanResult.message || scanResult.error || 'Failed to start');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -252,13 +271,28 @@ function startScanner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopScanner() {
|
function stopScanner() {
|
||||||
fetch('/listening/scanner/stop', { method: 'POST' })
|
const isAgentMode = listeningPostCurrentAgent !== null;
|
||||||
|
const endpoint = isAgentMode
|
||||||
|
? `/controller/agents/${listeningPostCurrentAgent}/listening_post/stop`
|
||||||
|
: '/listening/scanner/stop';
|
||||||
|
|
||||||
|
fetch(endpoint, { method: 'POST' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (typeof releaseDevice === 'function') releaseDevice('scanner');
|
if (!isAgentMode && typeof releaseDevice === 'function') releaseDevice('scanner');
|
||||||
|
listeningPostCurrentAgent = null;
|
||||||
isScannerRunning = false;
|
isScannerRunning = false;
|
||||||
isScannerPaused = false;
|
isScannerPaused = false;
|
||||||
scannerSignalActive = false;
|
scannerSignalActive = false;
|
||||||
|
|
||||||
|
// Re-enable listen button (will be in local mode after stop)
|
||||||
|
updateListenButtonState(false);
|
||||||
|
|
||||||
|
// Clear polling timer
|
||||||
|
if (listeningPostPollTimer) {
|
||||||
|
clearInterval(listeningPostPollTimer);
|
||||||
|
listeningPostPollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Update sidebar (with null checks)
|
// Update sidebar (with null checks)
|
||||||
const startBtn = document.getElementById('scannerStartBtn');
|
const startBtn = document.getElementById('scannerStartBtn');
|
||||||
if (startBtn) {
|
if (startBtn) {
|
||||||
@@ -386,17 +420,29 @@ function skipSignal() {
|
|||||||
|
|
||||||
// ============== SCANNER STREAM ==============
|
// ============== SCANNER STREAM ==============
|
||||||
|
|
||||||
function connectScannerStream() {
|
function connectScannerStream(isAgentMode = false) {
|
||||||
if (scannerEventSource) {
|
if (scannerEventSource) {
|
||||||
scannerEventSource.close();
|
scannerEventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
scannerEventSource = new EventSource('/listening/scanner/stream');
|
// Use different stream endpoint for agent mode
|
||||||
|
const streamUrl = isAgentMode ? '/controller/stream/all' : '/listening/scanner/stream';
|
||||||
|
scannerEventSource = new EventSource(streamUrl);
|
||||||
|
|
||||||
scannerEventSource.onmessage = function(e) {
|
scannerEventSource.onmessage = function(e) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
handleScannerEvent(data);
|
|
||||||
|
if (isAgentMode) {
|
||||||
|
// Handle multi-agent stream format
|
||||||
|
if (data.scan_type === 'listening_post' && data.payload) {
|
||||||
|
const payload = data.payload;
|
||||||
|
payload.agent_name = data.agent_name;
|
||||||
|
handleScannerEvent(payload);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleScannerEvent(data);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Scanner parse error:', err);
|
console.warn('Scanner parse error:', err);
|
||||||
}
|
}
|
||||||
@@ -404,9 +450,86 @@ function connectScannerStream() {
|
|||||||
|
|
||||||
scannerEventSource.onerror = function() {
|
scannerEventSource.onerror = function() {
|
||||||
if (isScannerRunning) {
|
if (isScannerRunning) {
|
||||||
setTimeout(connectScannerStream, 2000);
|
setTimeout(() => connectScannerStream(isAgentMode), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Start polling fallback for agent mode
|
||||||
|
if (isAgentMode) {
|
||||||
|
startListeningPostPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track last activity count for polling
|
||||||
|
let lastListeningPostActivityCount = 0;
|
||||||
|
|
||||||
|
function startListeningPostPolling() {
|
||||||
|
if (listeningPostPollTimer) return;
|
||||||
|
lastListeningPostActivityCount = 0;
|
||||||
|
|
||||||
|
// Disable listen button for agent mode (audio can't stream over HTTP)
|
||||||
|
updateListenButtonState(true);
|
||||||
|
|
||||||
|
const pollInterval = 2000;
|
||||||
|
listeningPostPollTimer = setInterval(async () => {
|
||||||
|
if (!isScannerRunning || !listeningPostCurrentAgent) {
|
||||||
|
clearInterval(listeningPostPollTimer);
|
||||||
|
listeningPostPollTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/controller/agents/${listeningPostCurrentAgent}/listening_post/data`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const result = data.result || data;
|
||||||
|
// Controller returns nested structure: data.data.data for agent mode data
|
||||||
|
const outerData = result.data || {};
|
||||||
|
const modeData = outerData.data || outerData;
|
||||||
|
|
||||||
|
// Process activity from polling response
|
||||||
|
const activity = modeData.activity || [];
|
||||||
|
if (activity.length > lastListeningPostActivityCount) {
|
||||||
|
const newActivity = activity.slice(lastListeningPostActivityCount);
|
||||||
|
newActivity.forEach(item => {
|
||||||
|
// Convert to scanner event format
|
||||||
|
const event = {
|
||||||
|
type: 'signal_found',
|
||||||
|
frequency: item.frequency,
|
||||||
|
level: item.level || item.signal_level,
|
||||||
|
modulation: item.modulation,
|
||||||
|
agent_name: result.agent_name || 'Remote Agent'
|
||||||
|
};
|
||||||
|
handleScannerEvent(event);
|
||||||
|
});
|
||||||
|
lastListeningPostActivityCount = activity.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current frequency if available
|
||||||
|
if (modeData.current_freq) {
|
||||||
|
handleScannerEvent({
|
||||||
|
type: 'freq_change',
|
||||||
|
frequency: modeData.current_freq
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update freqs scanned counter from agent data
|
||||||
|
if (modeData.freqs_scanned !== undefined) {
|
||||||
|
const freqsEl = document.getElementById('mainFreqsScanned');
|
||||||
|
if (freqsEl) freqsEl.textContent = modeData.freqs_scanned;
|
||||||
|
scannerFreqsScanned = modeData.freqs_scanned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update signal count from agent data
|
||||||
|
if (modeData.signal_count !== undefined) {
|
||||||
|
const signalEl = document.getElementById('mainSignalCount');
|
||||||
|
if (signalEl) signalEl.textContent = modeData.signal_count;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Listening Post polling error:', err);
|
||||||
|
}
|
||||||
|
}, pollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScannerEvent(data) {
|
function handleScannerEvent(data) {
|
||||||
@@ -576,6 +699,27 @@ function handleSignalLost(data) {
|
|||||||
addScannerLogEntry(logTitle, `${data.frequency.toFixed(3)} MHz`, logType);
|
addScannerLogEntry(logTitle, `${data.frequency.toFixed(3)} MHz`, logType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update listen button state based on agent mode
|
||||||
|
* Audio streaming isn't practical over HTTP so disable for remote agents
|
||||||
|
*/
|
||||||
|
function updateListenButtonState(isAgentMode) {
|
||||||
|
const listenBtn = document.getElementById('radioListenBtn');
|
||||||
|
if (!listenBtn) return;
|
||||||
|
|
||||||
|
if (isAgentMode) {
|
||||||
|
listenBtn.disabled = true;
|
||||||
|
listenBtn.style.opacity = '0.5';
|
||||||
|
listenBtn.style.cursor = 'not-allowed';
|
||||||
|
listenBtn.title = 'Audio listening not available for remote agents';
|
||||||
|
} else {
|
||||||
|
listenBtn.disabled = false;
|
||||||
|
listenBtn.style.opacity = '1';
|
||||||
|
listenBtn.style.cursor = 'pointer';
|
||||||
|
listenBtn.title = 'Listen to current frequency';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateScannerDisplay(mode, color) {
|
function updateScannerDisplay(mode, color) {
|
||||||
const modeLabel = document.getElementById('scannerModeLabel');
|
const modeLabel = document.getElementById('scannerModeLabel');
|
||||||
if (modeLabel) {
|
if (modeLabel) {
|
||||||
@@ -2286,6 +2430,67 @@ function addSidebarRecentSignal(freq, mod) {
|
|||||||
// Load bookmarks on init
|
// Load bookmarks on init
|
||||||
document.addEventListener('DOMContentLoaded', loadFrequencyBookmarks);
|
document.addEventListener('DOMContentLoaded', loadFrequencyBookmarks);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set listening post running state from external source (agent sync).
|
||||||
|
* Called by syncModeUI in agents.js when switching to an agent that already has scan running.
|
||||||
|
*/
|
||||||
|
function setListeningPostRunning(isRunning, agentId = null) {
|
||||||
|
console.log(`[ListeningPost] setListeningPostRunning: ${isRunning}, agent: ${agentId}`);
|
||||||
|
|
||||||
|
isScannerRunning = isRunning;
|
||||||
|
|
||||||
|
if (isRunning && agentId !== null && agentId !== 'local') {
|
||||||
|
// Agent has scan running - sync UI and start polling
|
||||||
|
listeningPostCurrentAgent = agentId;
|
||||||
|
|
||||||
|
// Update main scan button (radioScanBtn is the actual ID)
|
||||||
|
const radioScanBtn = document.getElementById('radioScanBtn');
|
||||||
|
if (radioScanBtn) {
|
||||||
|
radioScanBtn.innerHTML = '<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="6" width="12" height="12"/></svg></span>STOP';
|
||||||
|
radioScanBtn.style.background = 'var(--accent-red)';
|
||||||
|
radioScanBtn.style.borderColor = 'var(--accent-red)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status display
|
||||||
|
updateScannerDisplay('SCANNING', 'var(--accent-green)');
|
||||||
|
|
||||||
|
// Disable listen button (can't stream audio from agent)
|
||||||
|
updateListenButtonState(true);
|
||||||
|
|
||||||
|
// Start polling for agent data
|
||||||
|
startListeningPostPolling();
|
||||||
|
} else if (!isRunning) {
|
||||||
|
// Not running - reset UI
|
||||||
|
listeningPostCurrentAgent = null;
|
||||||
|
|
||||||
|
// Reset scan button
|
||||||
|
const radioScanBtn = document.getElementById('radioScanBtn');
|
||||||
|
if (radioScanBtn) {
|
||||||
|
radioScanBtn.innerHTML = '<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg></span>SCAN';
|
||||||
|
radioScanBtn.style.background = '';
|
||||||
|
radioScanBtn.style.borderColor = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
updateScannerDisplay('IDLE', 'var(--text-secondary)');
|
||||||
|
|
||||||
|
// Only re-enable listen button if we're in local mode
|
||||||
|
// (agent mode can't stream audio over HTTP)
|
||||||
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
updateListenButtonState(isAgentMode);
|
||||||
|
|
||||||
|
// Clear polling
|
||||||
|
if (listeningPostPollTimer) {
|
||||||
|
clearInterval(listeningPostPollTimer);
|
||||||
|
listeningPostPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for agent sync
|
||||||
|
window.setListeningPostRunning = setListeningPostRunning;
|
||||||
|
window.updateListenButtonState = updateListenButtonState;
|
||||||
|
|
||||||
// Export functions for HTML onclick handlers
|
// Export functions for HTML onclick handlers
|
||||||
window.toggleDirectListen = toggleDirectListen;
|
window.toggleDirectListen = toggleDirectListen;
|
||||||
window.startDirectListen = startDirectListen;
|
window.startDirectListen = startDirectListen;
|
||||||
|
|||||||
@@ -28,6 +28,47 @@ const WiFiMode = (function() {
|
|||||||
maxProbes: 1000,
|
maxProbes: 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Agent Support
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the API base URL, routing through agent proxy if agent is selected.
|
||||||
|
*/
|
||||||
|
function getApiBase() {
|
||||||
|
if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') {
|
||||||
|
return `/controller/agents/${currentAgent}/wifi/v2`;
|
||||||
|
}
|
||||||
|
return CONFIG.apiBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current agent name for tagging data.
|
||||||
|
*/
|
||||||
|
function getCurrentAgentName() {
|
||||||
|
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||||
|
return 'Local';
|
||||||
|
}
|
||||||
|
if (typeof agents !== 'undefined') {
|
||||||
|
const agent = agents.find(a => a.id == currentAgent);
|
||||||
|
return agent ? agent.name : `Agent ${currentAgent}`;
|
||||||
|
}
|
||||||
|
return `Agent ${currentAgent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for agent mode conflicts before starting WiFi scan.
|
||||||
|
*/
|
||||||
|
function checkAgentConflicts() {
|
||||||
|
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof checkAgentModeConflict === 'function') {
|
||||||
|
return checkAgentModeConflict('wifi');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// State
|
// State
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -49,6 +90,10 @@ const WiFiMode = (function() {
|
|||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
let currentSort = { field: 'rssi', order: 'desc' };
|
let currentSort = { field: 'rssi', order: 'desc' };
|
||||||
|
|
||||||
|
// Agent state
|
||||||
|
let showAllAgentsMode = false; // Show combined results from all agents
|
||||||
|
let lastAgentId = null; // Track agent switches
|
||||||
|
|
||||||
// Capabilities
|
// Capabilities
|
||||||
let capabilities = null;
|
let capabilities = null;
|
||||||
|
|
||||||
@@ -154,11 +199,43 @@ const WiFiMode = (function() {
|
|||||||
|
|
||||||
async function checkCapabilities() {
|
async function checkCapabilities() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${CONFIG.apiBase}/capabilities`);
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
if (!response.ok) throw new Error('Failed to fetch capabilities');
|
let response;
|
||||||
|
|
||||||
capabilities = await response.json();
|
if (isAgentMode) {
|
||||||
console.log('[WiFiMode] Capabilities:', capabilities);
|
// Fetch capabilities from agent via controller proxy
|
||||||
|
response = await fetch(`/controller/agents/${currentAgent}?refresh=true`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch agent capabilities');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
// Extract WiFi capabilities from agent data
|
||||||
|
if (data.agent && data.agent.capabilities) {
|
||||||
|
const agentCaps = data.agent.capabilities;
|
||||||
|
const agentInterfaces = data.agent.interfaces || {};
|
||||||
|
|
||||||
|
// Build WiFi-compatible capabilities object
|
||||||
|
capabilities = {
|
||||||
|
can_quick_scan: agentCaps.wifi || false,
|
||||||
|
can_deep_scan: agentCaps.wifi || false,
|
||||||
|
interfaces: (agentInterfaces.wifi_interfaces || []).map(iface => ({
|
||||||
|
name: iface.name || iface,
|
||||||
|
supports_monitor: iface.supports_monitor !== false
|
||||||
|
})),
|
||||||
|
default_interface: agentInterfaces.default_wifi || null,
|
||||||
|
preferred_quick_tool: 'agent',
|
||||||
|
issues: []
|
||||||
|
};
|
||||||
|
console.log('[WiFiMode] Agent capabilities:', capabilities);
|
||||||
|
} else {
|
||||||
|
throw new Error('Agent does not support WiFi mode');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Local capabilities
|
||||||
|
response = await fetch(`${CONFIG.apiBase}/capabilities`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch capabilities');
|
||||||
|
capabilities = await response.json();
|
||||||
|
console.log('[WiFiMode] Local capabilities:', capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
updateCapabilityUI();
|
updateCapabilityUI();
|
||||||
populateInterfaceSelect();
|
populateInterfaceSelect();
|
||||||
@@ -282,17 +359,34 @@ const WiFiMode = (function() {
|
|||||||
async function startQuickScan() {
|
async function startQuickScan() {
|
||||||
if (isScanning) return;
|
if (isScanning) return;
|
||||||
|
|
||||||
|
// Check for agent mode conflicts
|
||||||
|
if (!checkAgentConflicts()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[WiFiMode] Starting quick scan...');
|
console.log('[WiFiMode] Starting quick scan...');
|
||||||
setScanning(true, 'quick');
|
setScanning(true, 'quick');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const iface = elements.interfaceSelect?.value || null;
|
const iface = elements.interfaceSelect?.value || null;
|
||||||
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
const agentName = getCurrentAgentName();
|
||||||
|
|
||||||
const response = await fetch(`${CONFIG.apiBase}/scan/quick`, {
|
let response;
|
||||||
method: 'POST',
|
if (isAgentMode) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// Route through agent proxy
|
||||||
body: JSON.stringify({ interface: iface }),
|
response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, {
|
||||||
});
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ interface: iface, scan_type: 'quick' }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch(`${CONFIG.apiBase}/scan/quick`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ interface: iface }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
@@ -302,20 +396,26 @@ const WiFiMode = (function() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[WiFiMode] Quick scan complete:', result);
|
console.log('[WiFiMode] Quick scan complete:', result);
|
||||||
|
|
||||||
|
// Handle controller proxy response format (agent response is nested in 'result')
|
||||||
|
const scanResult = isAgentMode && result.result ? result.result : result;
|
||||||
|
|
||||||
// Check for error first
|
// Check for error first
|
||||||
if (result.error) {
|
if (scanResult.error || scanResult.status === 'error') {
|
||||||
console.error('[WiFiMode] Quick scan error from server:', result.error);
|
console.error('[WiFiMode] Quick scan error from server:', scanResult.error || scanResult.message);
|
||||||
showError(result.error);
|
showError(scanResult.error || scanResult.message || 'Quick scan failed');
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle agent response format
|
||||||
|
let accessPoints = scanResult.access_points || scanResult.networks || [];
|
||||||
|
|
||||||
// Check if we got results
|
// Check if we got results
|
||||||
if (!result.access_points || result.access_points.length === 0) {
|
if (accessPoints.length === 0) {
|
||||||
// No error but no results
|
// No error but no results
|
||||||
let msg = 'Quick scan found no networks in range.';
|
let msg = 'Quick scan found no networks in range.';
|
||||||
if (result.warnings && result.warnings.length > 0) {
|
if (scanResult.warnings && scanResult.warnings.length > 0) {
|
||||||
msg += ' Warnings: ' + result.warnings.join('; ');
|
msg += ' Warnings: ' + scanResult.warnings.join('; ');
|
||||||
}
|
}
|
||||||
console.warn('[WiFiMode] ' + msg);
|
console.warn('[WiFiMode] ' + msg);
|
||||||
showError(msg + ' Try Deep Scan with monitor mode.');
|
showError(msg + ' Try Deep Scan with monitor mode.');
|
||||||
@@ -323,13 +423,18 @@ const WiFiMode = (function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag results with agent source
|
||||||
|
accessPoints.forEach(ap => {
|
||||||
|
ap._agent = agentName;
|
||||||
|
});
|
||||||
|
|
||||||
// Show any warnings even on success
|
// Show any warnings even on success
|
||||||
if (result.warnings && result.warnings.length > 0) {
|
if (scanResult.warnings && scanResult.warnings.length > 0) {
|
||||||
console.warn('[WiFiMode] Quick scan warnings:', result.warnings);
|
console.warn('[WiFiMode] Quick scan warnings:', scanResult.warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process results
|
// Process results
|
||||||
processQuickScanResult(result);
|
processQuickScanResult({ ...scanResult, access_points: accessPoints });
|
||||||
|
|
||||||
// For quick scan, we're done after one scan
|
// For quick scan, we're done after one scan
|
||||||
// But keep polling if user wants continuous updates
|
// But keep polling if user wants continuous updates
|
||||||
@@ -346,6 +451,11 @@ const WiFiMode = (function() {
|
|||||||
async function startDeepScan() {
|
async function startDeepScan() {
|
||||||
if (isScanning) return;
|
if (isScanning) return;
|
||||||
|
|
||||||
|
// Check for agent mode conflicts
|
||||||
|
if (!checkAgentConflicts()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[WiFiMode] Starting deep scan...');
|
console.log('[WiFiMode] Starting deep scan...');
|
||||||
setScanning(true, 'deep');
|
setScanning(true, 'deep');
|
||||||
|
|
||||||
@@ -353,22 +463,48 @@ const WiFiMode = (function() {
|
|||||||
const iface = elements.interfaceSelect?.value || null;
|
const iface = elements.interfaceSelect?.value || null;
|
||||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||||
const channel = document.getElementById('wifiChannel')?.value || null;
|
const channel = document.getElementById('wifiChannel')?.value || null;
|
||||||
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
|
||||||
const response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
let response;
|
||||||
method: 'POST',
|
if (isAgentMode) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// Route through agent proxy
|
||||||
body: JSON.stringify({
|
response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, {
|
||||||
interface: iface,
|
method: 'POST',
|
||||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
channel: channel ? parseInt(channel) : null,
|
body: JSON.stringify({
|
||||||
}),
|
interface: iface,
|
||||||
});
|
scan_type: 'deep',
|
||||||
|
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||||
|
channel: channel ? parseInt(channel) : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
interface: iface,
|
||||||
|
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||||
|
channel: channel ? parseInt(channel) : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.error || 'Failed to start deep scan');
|
throw new Error(error.error || 'Failed to start deep scan');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for agent error in response
|
||||||
|
if (isAgentMode) {
|
||||||
|
const result = await response.json();
|
||||||
|
const scanResult = result.result || result;
|
||||||
|
if (scanResult.status === 'error') {
|
||||||
|
throw new Error(scanResult.message || 'Agent failed to start deep scan');
|
||||||
|
}
|
||||||
|
console.log('[WiFiMode] Agent deep scan started:', scanResult);
|
||||||
|
}
|
||||||
|
|
||||||
// Start SSE stream for real-time updates
|
// Start SSE stream for real-time updates
|
||||||
startEventStream();
|
startEventStream();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -393,13 +529,17 @@ const WiFiMode = (function() {
|
|||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop deep scan on server
|
// Stop scan on server (local or agent)
|
||||||
if (scanMode === 'deep') {
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
try {
|
|
||||||
|
try {
|
||||||
|
if (isAgentMode) {
|
||||||
|
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { method: 'POST' });
|
||||||
|
} else if (scanMode === 'deep') {
|
||||||
await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' });
|
await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' });
|
||||||
} catch (error) {
|
|
||||||
console.warn('[WiFiMode] Error stopping scan:', error);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WiFiMode] Error stopping scan:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
@@ -431,12 +571,19 @@ const WiFiMode = (function() {
|
|||||||
|
|
||||||
async function checkScanStatus() {
|
async function checkScanStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${CONFIG.apiBase}/scan/status`);
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
const endpoint = isAgentMode
|
||||||
|
? `/controller/agents/${currentAgent}/wifi/status`
|
||||||
|
: `${CONFIG.apiBase}/scan/status`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint);
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
|
|
||||||
const status = await response.json();
|
const data = await response.json();
|
||||||
|
// Handle agent response format (may be nested in 'result')
|
||||||
|
const status = isAgentMode && data.result ? data.result : data;
|
||||||
|
|
||||||
if (status.is_scanning) {
|
if (status.is_scanning || status.running) {
|
||||||
setScanning(true, status.scan_mode);
|
setScanning(true, status.scan_mode);
|
||||||
if (status.scan_mode === 'deep') {
|
if (status.scan_mode === 'deep') {
|
||||||
startEventStream();
|
startEventStream();
|
||||||
@@ -517,8 +664,20 @@ const WiFiMode = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[WiFiMode] Starting event stream...');
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
eventSource = new EventSource(`${CONFIG.apiBase}/stream`);
|
const agentName = getCurrentAgentName();
|
||||||
|
let streamUrl;
|
||||||
|
|
||||||
|
if (isAgentMode) {
|
||||||
|
// Use multi-agent stream for remote agents
|
||||||
|
streamUrl = '/controller/stream/all';
|
||||||
|
console.log('[WiFiMode] Starting multi-agent event stream...');
|
||||||
|
} else {
|
||||||
|
streamUrl = `${CONFIG.apiBase}/stream`;
|
||||||
|
console.log('[WiFiMode] Starting local event stream...');
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource = new EventSource(streamUrl);
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
console.log('[WiFiMode] Event stream connected');
|
console.log('[WiFiMode] Event stream connected');
|
||||||
@@ -527,7 +686,46 @@ const WiFiMode = (function() {
|
|||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
handleStreamEvent(data);
|
|
||||||
|
// For multi-agent stream, filter and transform data
|
||||||
|
if (isAgentMode) {
|
||||||
|
// Skip keepalive and non-wifi data
|
||||||
|
if (data.type === 'keepalive') return;
|
||||||
|
if (data.scan_type !== 'wifi') return;
|
||||||
|
|
||||||
|
// Filter by current agent if not in "show all" mode
|
||||||
|
if (!showAllAgentsMode && typeof agents !== 'undefined') {
|
||||||
|
const currentAgentObj = agents.find(a => a.id == currentAgent);
|
||||||
|
if (currentAgentObj && data.agent_name && data.agent_name !== currentAgentObj.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform multi-agent payload to stream event format
|
||||||
|
if (data.payload && data.payload.networks) {
|
||||||
|
data.payload.networks.forEach(net => {
|
||||||
|
net._agent = data.agent_name || 'Unknown';
|
||||||
|
handleStreamEvent({
|
||||||
|
type: 'network_update',
|
||||||
|
network: net
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.payload && data.payload.clients) {
|
||||||
|
data.payload.clients.forEach(client => {
|
||||||
|
client._agent = data.agent_name || 'Unknown';
|
||||||
|
handleStreamEvent({
|
||||||
|
type: 'client_update',
|
||||||
|
client: client
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Local stream - tag with local
|
||||||
|
if (data.network) data.network._agent = 'Local';
|
||||||
|
if (data.client) data.client._agent = 'Local';
|
||||||
|
handleStreamEvent(data);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.debug('[WiFiMode] Event parse error:', error);
|
console.debug('[WiFiMode] Event parse error:', error);
|
||||||
}
|
}
|
||||||
@@ -745,6 +943,10 @@ const WiFiMode = (function() {
|
|||||||
const hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : '';
|
const hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : '';
|
||||||
const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : '';
|
const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : '';
|
||||||
|
|
||||||
|
// Agent source badge
|
||||||
|
const agentName = network._agent || 'Local';
|
||||||
|
const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
|
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
|
||||||
data-bssid="${escapeHtml(network.bssid)}"
|
data-bssid="${escapeHtml(network.bssid)}"
|
||||||
@@ -762,6 +964,9 @@ const WiFiMode = (function() {
|
|||||||
<span class="security-badge ${securityClass}">${escapeHtml(network.security)}</span>
|
<span class="security-badge ${securityClass}">${escapeHtml(network.security)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-clients">${network.client_count || 0}</td>
|
<td class="col-clients">${network.client_count || 0}</td>
|
||||||
|
<td class="col-agent">
|
||||||
|
<span class="agent-badge ${agentClass}">${escapeHtml(agentName)}</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1071,6 +1276,113 @@ const WiFiMode = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Agent Handling
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle agent change - refresh interfaces and optionally clear data.
|
||||||
|
* Called when user selects a different agent.
|
||||||
|
*/
|
||||||
|
function handleAgentChange() {
|
||||||
|
const currentAgentId = typeof currentAgent !== 'undefined' ? currentAgent : 'local';
|
||||||
|
|
||||||
|
// Check if agent actually changed
|
||||||
|
if (lastAgentId === currentAgentId) return;
|
||||||
|
|
||||||
|
console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId);
|
||||||
|
|
||||||
|
// Stop any running scan
|
||||||
|
if (isScanning) {
|
||||||
|
stopScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing data when switching agents (unless "Show All" is enabled)
|
||||||
|
if (!showAllAgentsMode) {
|
||||||
|
clearData();
|
||||||
|
showInfo(`Switched to ${getCurrentAgentName()} - previous data cleared`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh capabilities for new agent
|
||||||
|
checkCapabilities();
|
||||||
|
|
||||||
|
lastAgentId = currentAgentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all collected data.
|
||||||
|
*/
|
||||||
|
function clearData() {
|
||||||
|
networks.clear();
|
||||||
|
clients.clear();
|
||||||
|
probeRequests = [];
|
||||||
|
channelStats = [];
|
||||||
|
recommendations = [];
|
||||||
|
|
||||||
|
updateNetworkTable();
|
||||||
|
updateStats();
|
||||||
|
updateProximityRadar();
|
||||||
|
updateChannelChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle "Show All Agents" mode.
|
||||||
|
* When enabled, displays combined WiFi results from all agents.
|
||||||
|
*/
|
||||||
|
function toggleShowAllAgents(enabled) {
|
||||||
|
showAllAgentsMode = enabled;
|
||||||
|
console.log('[WiFiMode] Show all agents mode:', enabled);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// If currently scanning, switch to multi-agent stream
|
||||||
|
if (isScanning && eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
startEventStream();
|
||||||
|
}
|
||||||
|
showInfo('Showing WiFi networks from all agents');
|
||||||
|
} else {
|
||||||
|
// Filter to current agent only
|
||||||
|
filterToCurrentAgent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter networks to only show those from current agent.
|
||||||
|
*/
|
||||||
|
function filterToCurrentAgent() {
|
||||||
|
const agentName = getCurrentAgentName();
|
||||||
|
const toRemove = [];
|
||||||
|
|
||||||
|
networks.forEach((network, bssid) => {
|
||||||
|
if (network._agent && network._agent !== agentName) {
|
||||||
|
toRemove.push(bssid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
toRemove.forEach(bssid => networks.delete(bssid));
|
||||||
|
|
||||||
|
// Also filter clients
|
||||||
|
const clientsToRemove = [];
|
||||||
|
clients.forEach((client, mac) => {
|
||||||
|
if (client._agent && client._agent !== agentName) {
|
||||||
|
clientsToRemove.push(mac);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clientsToRemove.forEach(mac => clients.delete(mac));
|
||||||
|
|
||||||
|
updateNetworkTable();
|
||||||
|
updateStats();
|
||||||
|
updateProximityRadar();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh WiFi interfaces from current agent.
|
||||||
|
* Called when agent changes.
|
||||||
|
*/
|
||||||
|
async function refreshInterfaces() {
|
||||||
|
await checkCapabilities();
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Public API
|
// Public API
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -1086,12 +1398,19 @@ const WiFiMode = (function() {
|
|||||||
exportData,
|
exportData,
|
||||||
checkCapabilities,
|
checkCapabilities,
|
||||||
|
|
||||||
|
// Agent handling
|
||||||
|
handleAgentChange,
|
||||||
|
clearData,
|
||||||
|
toggleShowAllAgents,
|
||||||
|
refreshInterfaces,
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
getNetworks: () => Array.from(networks.values()),
|
getNetworks: () => Array.from(networks.values()),
|
||||||
getClients: () => Array.from(clients.values()),
|
getClients: () => Array.from(clients.values()),
|
||||||
getProbes: () => [...probeRequests],
|
getProbes: () => [...probeRequests],
|
||||||
isScanning: () => isScanning,
|
isScanning: () => isScanning,
|
||||||
getScanMode: () => scanMode,
|
getScanMode: () => scanMode,
|
||||||
|
isShowAllAgents: () => showAllAgentsMode,
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
onNetworkUpdate: (cb) => { onNetworkUpdate = cb; },
|
onNetworkUpdate: (cb) => { onNetworkUpdate = cb; },
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,661 @@
|
|||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>iNTERCEPT // Remote Agents</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
||||||
|
<style>
|
||||||
|
.agents-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card.offline {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status-dot.online {
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 6px var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status-dot.offline {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status-dot.unknown {
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-capabilities {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-badge.disabled {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #666;
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-btn:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-btn.danger:hover {
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-btn.primary {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-btn.primary:hover {
|
||||||
|
background: #00b8d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add Agent Form */
|
||||||
|
.add-agent-section {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-agent-section h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation links */
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast notifications */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header style="padding: 15px 20px; display: flex; align-items: center; gap: 12px;">
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||||
|
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||||
|
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||||
|
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 style="margin: 0;">
|
||||||
|
iNTERCEPT <span class="tagline">// Remote Agents</span>
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="agents-container">
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#" onclick="history.back(); return false;" class="back-link">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
<a href="/" class="back-link">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agents-header">
|
||||||
|
<h1>Remote Agents</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Agent Form -->
|
||||||
|
<div class="add-agent-section">
|
||||||
|
<h2>Register New Agent</h2>
|
||||||
|
<form id="addAgentForm" onsubmit="return addAgent(event)">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="agentName">Agent Name *</label>
|
||||||
|
<input type="text" id="agentName" placeholder="sensor-node-1" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="agentUrl">Base URL *</label>
|
||||||
|
<input type="url" id="agentUrl" placeholder="http://192.168.1.50:8020" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="agentApiKey">API Key (optional)</label>
|
||||||
|
<input type="text" id="agentApiKey" placeholder="shared-secret">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="agentDescription">Description (optional)</label>
|
||||||
|
<input type="text" id="agentDescription" placeholder="Rooftop sensor node">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<button type="submit" class="agent-btn primary" style="width: auto; padding: 10px 24px;">
|
||||||
|
Register Agent
|
||||||
|
</button>
|
||||||
|
<button type="button" class="agent-btn" style="width: auto; padding: 10px 24px;" onclick="refreshAllAgents()">
|
||||||
|
Refresh All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agents Grid -->
|
||||||
|
<div id="agentsGrid" class="agents-grid">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emptyState" class="empty-state" style="display: none;">
|
||||||
|
<div class="empty-state-icon">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2"/>
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
||||||
|
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>No Remote Agents</h3>
|
||||||
|
<p>Register your first remote agent to get started with distributed signal intelligence.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Agent management functions
|
||||||
|
let agents = [];
|
||||||
|
|
||||||
|
async function loadAgents() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/controller/agents?refresh=true', {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
agents = data.agents || [];
|
||||||
|
renderAgents();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load agents:', error);
|
||||||
|
showToast('Failed to load agents', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgents() {
|
||||||
|
const grid = document.getElementById('agentsGrid');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
|
||||||
|
if (agents.length === 0) {
|
||||||
|
grid.innerHTML = '';
|
||||||
|
emptyState.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
|
||||||
|
grid.innerHTML = agents.map(agent => {
|
||||||
|
const isOnline = agent.healthy !== false && agent.is_active;
|
||||||
|
const statusClass = isOnline ? 'online' : 'offline';
|
||||||
|
const statusText = isOnline ? 'Online' : 'Offline';
|
||||||
|
|
||||||
|
const capabilities = agent.capabilities || {};
|
||||||
|
const capBadges = Object.entries(capabilities)
|
||||||
|
.filter(([k, v]) => v === true)
|
||||||
|
.map(([k]) => `<span class="capability-badge">${k}</span>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const lastSeen = agent.last_seen
|
||||||
|
? new Date(agent.last_seen).toLocaleString()
|
||||||
|
: 'Never';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="agent-card ${statusClass}">
|
||||||
|
<div class="agent-card-header">
|
||||||
|
<span class="agent-name">${escapeHtml(agent.name)}</span>
|
||||||
|
<span class="agent-status">
|
||||||
|
<span class="agent-status-dot ${statusClass}"></span>
|
||||||
|
${statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="agent-url">${escapeHtml(agent.base_url)}</div>
|
||||||
|
<div class="agent-capabilities">
|
||||||
|
${capBadges || '<span class="capability-badge disabled">No capabilities detected</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="agent-meta">
|
||||||
|
Last seen: ${lastSeen}<br>
|
||||||
|
${agent.description ? `Note: ${escapeHtml(agent.description)}` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="agent-actions">
|
||||||
|
<button class="agent-btn" onclick="refreshAgent(${agent.id})">Refresh</button>
|
||||||
|
<button class="agent-btn" onclick="testAgent(${agent.id})">Test</button>
|
||||||
|
<button class="agent-btn danger" onclick="deleteAgent(${agent.id}, '${escapeHtml(agent.name)}')">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAgent(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const name = document.getElementById('agentName').value.trim();
|
||||||
|
const baseUrl = document.getElementById('agentUrl').value.trim();
|
||||||
|
const apiKey = document.getElementById('agentApiKey').value.trim();
|
||||||
|
const description = document.getElementById('agentDescription').value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/controller/agents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
base_url: baseUrl,
|
||||||
|
api_key: apiKey || null,
|
||||||
|
description: description || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast(`Agent "${name}" registered successfully`, 'success');
|
||||||
|
document.getElementById('addAgentForm').reset();
|
||||||
|
loadAgents();
|
||||||
|
} else {
|
||||||
|
showToast(data.message || 'Failed to register agent', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding agent:', error);
|
||||||
|
showToast('Failed to register agent', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAgent(agentId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/controller/agents/${agentId}/refresh`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('Agent refreshed', 'success');
|
||||||
|
loadAgents();
|
||||||
|
} else {
|
||||||
|
showToast(data.message || 'Failed to refresh agent', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to refresh agent', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAllAgents() {
|
||||||
|
showToast('Refreshing all agents...', 'info');
|
||||||
|
await loadAgents();
|
||||||
|
showToast('All agents refreshed', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAgent(agentId) {
|
||||||
|
const agent = agents.find(a => a.id === agentId);
|
||||||
|
if (!agent) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/controller/agents/${agentId}?refresh=true`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.agent && data.agent.healthy !== false) {
|
||||||
|
showToast(`Agent "${agent.name}" is responding`, 'success');
|
||||||
|
} else {
|
||||||
|
showToast(`Agent "${agent.name}" is not responding`, 'error');
|
||||||
|
}
|
||||||
|
loadAgents();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Cannot reach agent "${agent.name}"`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAgent(agentId, agentName) {
|
||||||
|
if (!confirm(`Are you sure you want to remove agent "${agentName}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/controller/agents/${agentId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast(`Agent "${agentName}" removed`, 'success');
|
||||||
|
loadAgents();
|
||||||
|
} else {
|
||||||
|
showToast('Failed to remove agent', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to remove agent', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
// Remove existing toasts
|
||||||
|
document.querySelectorAll('.toast').forEach(t => t.remove());
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => toast.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load agents on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', loadAgents);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,9 +4,20 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
|
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
|
||||||
|
<!-- Fonts - Conditional CDN/Local loading -->
|
||||||
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
||||||
|
{% if offline_settings.assets_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||||
|
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||||
|
{% else %}
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
{% endif %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
</head>
|
</head>
|
||||||
@@ -21,6 +32,17 @@
|
|||||||
<span>// INTERCEPT - AIS Tracking</span>
|
<span>// INTERCEPT - AIS Tracking</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
|
<!-- Agent Selector -->
|
||||||
|
<div class="agent-selector-compact" id="agentSection">
|
||||||
|
<select id="agentSelect" class="agent-select-sm" title="Select signal source">
|
||||||
|
<option value="local">Local</option>
|
||||||
|
</select>
|
||||||
|
<span class="agent-status-dot online" id="agentStatusDot"></span>
|
||||||
|
<label class="show-all-label" title="Show vessels from all agents on map">
|
||||||
|
<input type="checkbox" id="showAllAgents" onchange="toggleShowAllAgents()"> All
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
|
||||||
<a href="/" class="back-link">Main Dashboard</a>
|
<a href="/" class="back-link">Main Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -78,7 +100,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="selected-info" id="selectedInfo">
|
<div class="selected-info" id="selectedInfo">
|
||||||
<div class="no-vessel">
|
<div class="no-vessel">
|
||||||
<div class="no-vessel-icon">🚢</div>
|
<div class="no-vessel-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" style="opacity: 0.5;">
|
||||||
|
<path fill="currentColor" d="M12 2L8 6V18L10 20H14L16 18V6L12 2Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div>Select a vessel</div>
|
<div>Select a vessel</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,6 +195,7 @@
|
|||||||
let markers = {};
|
let markers = {};
|
||||||
let selectedMmsi = null;
|
let selectedMmsi = null;
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
|
let aisPollTimer = null; // Polling fallback for agent mode
|
||||||
let isTracking = false;
|
let isTracking = false;
|
||||||
|
|
||||||
// DSC State
|
// DSC State
|
||||||
@@ -177,6 +204,8 @@
|
|||||||
let dscMessages = {};
|
let dscMessages = {};
|
||||||
let dscMarkers = {};
|
let dscMarkers = {};
|
||||||
let dscAlertCounts = { distress: 0, urgency: 0 };
|
let dscAlertCounts = { distress: 0, urgency: 0 };
|
||||||
|
let dscCurrentAgent = null;
|
||||||
|
let dscPollTimer = null;
|
||||||
let showTrails = false;
|
let showTrails = false;
|
||||||
let vesselTrails = {};
|
let vesselTrails = {};
|
||||||
let trailLines = {};
|
let trailLines = {};
|
||||||
@@ -204,34 +233,48 @@
|
|||||||
let messageRateInterval = null;
|
let messageRateInterval = null;
|
||||||
let lastMessageCount = 0;
|
let lastMessageCount = 0;
|
||||||
|
|
||||||
// Ship type to icon mapping
|
// Vessel SVG icon paths (top-down view, pointing up)
|
||||||
const SHIP_ICONS = {
|
const VESSEL_ICONS = {
|
||||||
30: '🐟', // Fishing
|
// Generic cargo/container ship - pointed bow, rectangular hull
|
||||||
31: '🚢', // Towing
|
cargo: 'M12 2L8 6V18L10 20H14L16 18V6L12 2ZM10 8H14V16H10V8Z',
|
||||||
32: '🚢', // Towing
|
// Tanker - rounded bow, long hull
|
||||||
36: '⛵', // Sailing
|
tanker: 'M12 2C10 2 8 4 8 6V18C8 19 9 20 10 20H14C15 20 16 19 16 18V6C16 4 14 2 12 2ZM10 8H14V16H10V8Z',
|
||||||
37: '⛵', // Pleasure craft
|
// Passenger/cruise - multiple decks indicated
|
||||||
60: '🚢', // Passenger
|
passenger: 'M12 2L8 5V18L10 20H14L16 18V5L12 2ZM9 7H15V10H9V7ZM9 11H15V14H9V11ZM9 15H15V18H9V15Z',
|
||||||
61: '🚢', // Passenger
|
// Tug - small, compact, powerful
|
||||||
62: '🚢', // Passenger
|
tug: 'M12 4L9 7V16L10 18H14L15 16V7L12 4ZM10 9H14V14H10V9Z',
|
||||||
63: '🚢', // Passenger
|
// Fishing vessel - with mast/outriggers
|
||||||
64: '🚢', // Passenger
|
fishing: 'M12 2L12 5L8 8V17L10 19H14L16 17V8L12 5ZM6 10L8 12V15L6 13V10ZM18 10V13L16 15V12L18 10ZM10 10H14V15H10V10Z',
|
||||||
65: '🚢', // Passenger
|
// Sailing vessel - sail shape
|
||||||
66: '🚢', // Passenger
|
sailing: 'M12 2L12 6L8 10V18L10 20H14L16 18V10L12 6ZM12 3L16 8H12V3ZM10 11H14V17H10V11Z',
|
||||||
67: '🚢', // Passenger
|
// Military - angular, aggressive bow
|
||||||
68: '🚢', // Passenger
|
military: 'M12 1L7 6V8L8 9V18L10 20H14L16 18V9L17 8V6L12 1ZM10 10H14V16H10V10Z',
|
||||||
69: '🚢', // Passenger
|
// High speed craft - sleek, pointed
|
||||||
70: '🚢', // Cargo
|
hsc: 'M12 1L9 5V18L10 20H14L15 18V5L12 1ZM10 7H14V17H10V7Z',
|
||||||
71: '🚢', // Cargo - hazardous A
|
// Search & rescue - distinctive cross marking
|
||||||
72: '🚢', // Cargo - hazardous B
|
sar: 'M12 2L8 6V18L10 20H14L16 18V6L12 2ZM11 8H13V11H16V13H13V16H11V13H8V11H11V8Z',
|
||||||
73: '🚢', // Cargo - hazardous C
|
// Pilot vessel
|
||||||
74: '🚢', // Cargo - hazardous D
|
pilot: 'M12 3L9 6V17L10 19H14L15 17V6L12 3ZM10 8H14V15H10V8ZM11 9V10H13V9H11Z',
|
||||||
80: '🚢', // Tanker
|
// Law enforcement
|
||||||
81: '🚢', // Tanker - hazardous A
|
law: 'M12 2L8 6V18L10 20H14L16 18V6L12 2ZM10 8H14V10H10V8ZM11 11H13V16H11V11Z',
|
||||||
82: '🚢', // Tanker - hazardous B
|
// Generic vessel (default)
|
||||||
83: '🚢', // Tanker - hazardous C
|
default: 'M12 2L8 6V18L10 20H14L16 18V6L12 2Z'
|
||||||
84: '🚢', // Tanker - hazardous D
|
};
|
||||||
default: '🚢' // Generic ship
|
|
||||||
|
// Vessel type colors
|
||||||
|
const VESSEL_COLORS = {
|
||||||
|
cargo: '#00d4ff', // Cyan
|
||||||
|
tanker: '#ff6b35', // Orange
|
||||||
|
passenger: '#a855f7', // Purple
|
||||||
|
tug: '#fbbf24', // Yellow
|
||||||
|
fishing: '#22c55e', // Green
|
||||||
|
sailing: '#60a5fa', // Light blue
|
||||||
|
military: '#ef4444', // Red
|
||||||
|
hsc: '#f472b6', // Pink
|
||||||
|
sar: '#ff0000', // Bright red
|
||||||
|
pilot: '#ffffff', // White
|
||||||
|
law: '#3b82f6', // Blue
|
||||||
|
default: '#00d4ff' // Cyan
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ship type categories
|
// Ship type categories
|
||||||
@@ -255,8 +298,50 @@
|
|||||||
return 'Other';
|
return 'Other';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getShipIcon(type) {
|
// Get vessel icon type from AIS ship type code
|
||||||
return SHIP_ICONS[type] || SHIP_ICONS.default;
|
function getVesselIconType(type) {
|
||||||
|
if (!type) return 'default';
|
||||||
|
if (type === 30) return 'fishing';
|
||||||
|
if (type >= 31 && type <= 32) return 'tug';
|
||||||
|
if (type === 35) return 'military';
|
||||||
|
if (type >= 36 && type <= 37) return 'sailing';
|
||||||
|
if (type >= 40 && type < 50) return 'hsc';
|
||||||
|
if (type === 50) return 'pilot';
|
||||||
|
if (type === 51) return 'sar';
|
||||||
|
if (type === 52) return 'tug';
|
||||||
|
if (type === 55) return 'law';
|
||||||
|
if (type >= 60 && type < 70) return 'passenger';
|
||||||
|
if (type >= 70 && type < 80) return 'cargo';
|
||||||
|
if (type >= 80 && type < 90) return 'tanker';
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SVG vessel marker icon
|
||||||
|
function createVesselMarkerIcon(rotation, vesselType, isSelected = false) {
|
||||||
|
const path = VESSEL_ICONS[vesselType] || VESSEL_ICONS.default;
|
||||||
|
const color = VESSEL_COLORS[vesselType] || VESSEL_COLORS.default;
|
||||||
|
const size = 24;
|
||||||
|
const glowColor = isSelected ? 'rgba(255,255,255,0.8)' : color;
|
||||||
|
const glowSize = isSelected ? '8px' : '4px';
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'vessel-marker' + (isSelected ? ' selected' : ''),
|
||||||
|
html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); filter: drop-shadow(0 0 ${glowSize} ${glowColor});">
|
||||||
|
<path fill="${color}" d="${path}"/>
|
||||||
|
</svg>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size/2, size/2]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy function for vessel list icons (returns SVG string)
|
||||||
|
function getShipIconSvg(type, size = 18) {
|
||||||
|
const vesselType = getVesselIconType(type);
|
||||||
|
const path = VESSEL_ICONS[vesselType] || VESSEL_ICONS.default;
|
||||||
|
const color = VESSEL_COLORS[vesselType] || VESSEL_COLORS.default;
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="vertical-align: middle;">
|
||||||
|
<path fill="${color}" d="${path}"/>
|
||||||
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation status text
|
// Navigation status text
|
||||||
@@ -430,6 +515,40 @@
|
|||||||
const device = document.getElementById('aisDeviceSelect').value;
|
const device = document.getElementById('aisDeviceSelect').value;
|
||||||
const gain = document.getElementById('aisGain').value;
|
const gain = document.getElementById('aisGain').value;
|
||||||
|
|
||||||
|
// Check if using agent mode
|
||||||
|
const useAgent = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
||||||
|
|
||||||
|
// For agent mode, check conflicts and route through proxy
|
||||||
|
if (useAgent) {
|
||||||
|
if (typeof checkAgentModeConflict === 'function' && !checkAgentModeConflict('ais')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ device, gain })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(result => {
|
||||||
|
const data = result.result || result;
|
||||||
|
if (data.status === 'started' || data.status === 'already_running') {
|
||||||
|
isTracking = true;
|
||||||
|
document.getElementById('startBtn').textContent = 'STOP';
|
||||||
|
document.getElementById('startBtn').classList.add('active');
|
||||||
|
document.getElementById('trackingDot').classList.add('active');
|
||||||
|
document.getElementById('trackingStatus').textContent = 'TRACKING';
|
||||||
|
startSessionTimer();
|
||||||
|
startSSE();
|
||||||
|
} else {
|
||||||
|
alert(data.message || 'Failed to start');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => alert('Error: ' + err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local mode - original behavior unchanged
|
||||||
fetch('/ais/start', {
|
fetch('/ais/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -453,7 +572,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopTracking() {
|
function stopTracking() {
|
||||||
fetch('/ais/stop', { method: 'POST' })
|
const useAgent = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
||||||
|
|
||||||
|
// Route to agent or local
|
||||||
|
const url = useAgent ? `/controller/agents/${aisCurrentAgent}/ais/stop` : '/ais/stop';
|
||||||
|
|
||||||
|
fetch(url, { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
isTracking = false;
|
isTracking = false;
|
||||||
@@ -467,18 +591,107 @@
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
if (aisPollTimer) {
|
||||||
|
clearInterval(aisPollTimer);
|
||||||
|
aisPollTimer = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start polling agent data as fallback when push isn't enabled.
|
||||||
|
*/
|
||||||
|
function startAisPolling() {
|
||||||
|
if (aisPollTimer) return;
|
||||||
|
if (typeof aisCurrentAgent === 'undefined' || aisCurrentAgent === 'local') return;
|
||||||
|
|
||||||
|
const pollInterval = 2000; // 2 seconds for AIS
|
||||||
|
console.log('Starting AIS agent polling fallback...');
|
||||||
|
|
||||||
|
aisPollTimer = setInterval(async () => {
|
||||||
|
if (!isTracking) {
|
||||||
|
clearInterval(aisPollTimer);
|
||||||
|
aisPollTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/controller/agents/${aisCurrentAgent}/ais/data`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
|
// Get agent name
|
||||||
|
let agentName = 'Agent';
|
||||||
|
if (typeof agents !== 'undefined') {
|
||||||
|
const agent = agents.find(a => a.id == aisCurrentAgent);
|
||||||
|
if (agent) agentName = agent.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process vessels from polling response
|
||||||
|
if (data && data.vessels) {
|
||||||
|
Object.values(data.vessels).forEach(vessel => {
|
||||||
|
vessel._agent = agentName;
|
||||||
|
updateVessel(vessel);
|
||||||
|
});
|
||||||
|
} else if (data && Array.isArray(data)) {
|
||||||
|
data.forEach(vessel => {
|
||||||
|
vessel._agent = agentName;
|
||||||
|
updateVessel(vessel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('AIS agent poll error:', err);
|
||||||
|
}
|
||||||
|
}, pollInterval);
|
||||||
|
}
|
||||||
|
|
||||||
function startSSE() {
|
function startSSE() {
|
||||||
if (eventSource) eventSource.close();
|
if (eventSource) eventSource.close();
|
||||||
|
|
||||||
eventSource = new EventSource('/ais/stream');
|
const useAgent = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
||||||
|
const streamUrl = useAgent ? '/controller/stream/all' : '/ais/stream';
|
||||||
|
|
||||||
|
// Get agent name for filtering
|
||||||
|
let targetAgentName = null;
|
||||||
|
if (useAgent && typeof agents !== 'undefined') {
|
||||||
|
const agent = agents.find(a => a.id == aisCurrentAgent);
|
||||||
|
targetAgentName = agent ? agent.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource = new EventSource(streamUrl);
|
||||||
eventSource.onmessage = function(e) {
|
eventSource.onmessage = function(e) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'vessel') {
|
|
||||||
updateVessel(data);
|
if (useAgent) {
|
||||||
|
// Multi-agent stream format
|
||||||
|
if (data.type === 'keepalive') return;
|
||||||
|
|
||||||
|
// Filter to our agent
|
||||||
|
if (targetAgentName && data.agent_name && data.agent_name !== targetAgentName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract vessel data from push payload
|
||||||
|
if (data.scan_type === 'ais' && data.payload) {
|
||||||
|
const payload = data.payload;
|
||||||
|
if (payload.vessels) {
|
||||||
|
Object.values(payload.vessels).forEach(v => {
|
||||||
|
v._agent = data.agent_name;
|
||||||
|
updateVessel({ type: 'vessel', ...v });
|
||||||
|
});
|
||||||
|
} else if (payload.mmsi) {
|
||||||
|
payload._agent = data.agent_name;
|
||||||
|
updateVessel({ type: 'vessel', ...payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Local stream format
|
||||||
|
if (data.type === 'vessel') {
|
||||||
|
updateVessel(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
};
|
};
|
||||||
@@ -544,20 +757,9 @@
|
|||||||
if (!vessel.lat || !vessel.lon) return;
|
if (!vessel.lat || !vessel.lon) return;
|
||||||
|
|
||||||
const heading = vessel.heading || vessel.course || 0;
|
const heading = vessel.heading || vessel.course || 0;
|
||||||
const icon = getShipIcon(vessel.ship_type);
|
const vesselType = getVesselIconType(vessel.ship_type);
|
||||||
|
const isSelected = mmsi === selectedMmsi;
|
||||||
const markerHtml = `
|
const divIcon = createVesselMarkerIcon(heading, vesselType, isSelected);
|
||||||
<div class="vessel-marker-inner" style="transform: rotate(${heading}deg);">
|
|
||||||
${icon}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const divIcon = L.divIcon({
|
|
||||||
className: 'vessel-marker' + (mmsi === selectedMmsi ? ' selected' : ''),
|
|
||||||
html: markerHtml,
|
|
||||||
iconSize: [24, 24],
|
|
||||||
iconAnchor: [12, 12]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (markers[mmsi]) {
|
if (markers[mmsi]) {
|
||||||
markers[mmsi].setLatLng([vessel.lat, vessel.lon]);
|
markers[mmsi].setLatLng([vessel.lat, vessel.lon]);
|
||||||
@@ -573,13 +775,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectVessel(mmsi) {
|
function selectVessel(mmsi) {
|
||||||
|
const prevSelected = selectedMmsi;
|
||||||
selectedMmsi = mmsi;
|
selectedMmsi = mmsi;
|
||||||
|
|
||||||
// Update marker styles
|
// Update marker icons for previous and new selection
|
||||||
Object.keys(markers).forEach(m => {
|
[prevSelected, mmsi].forEach(m => {
|
||||||
const el = markers[m].getElement();
|
if (m && vessels[m] && markers[m]) {
|
||||||
if (el) {
|
const vessel = vessels[m];
|
||||||
el.querySelector('.vessel-marker-inner')?.parentElement?.classList.toggle('selected', m === mmsi);
|
const heading = vessel.heading || vessel.course || 0;
|
||||||
|
const vesselType = getVesselIconType(vessel.ship_type);
|
||||||
|
const isSelected = m === mmsi;
|
||||||
|
markers[m].setIcon(createVesselMarkerIcon(heading, vesselType, isSelected));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -595,13 +801,13 @@
|
|||||||
|
|
||||||
function showVesselDetails(vessel) {
|
function showVesselDetails(vessel) {
|
||||||
const container = document.getElementById('selectedInfo');
|
const container = document.getElementById('selectedInfo');
|
||||||
const icon = getShipIcon(vessel.ship_type);
|
const iconSvg = getShipIconSvg(vessel.ship_type, 28);
|
||||||
const category = getShipCategory(vessel.ship_type);
|
const category = getShipCategory(vessel.ship_type);
|
||||||
const navStatus = NAV_STATUS[vessel.nav_status] || vessel.nav_status_text || 'Unknown';
|
const navStatus = NAV_STATUS[vessel.nav_status] || vessel.nav_status_text || 'Unknown';
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="vessel-header">
|
<div class="vessel-header">
|
||||||
<div class="vessel-icon">${icon}</div>
|
<div class="vessel-icon">${iconSvg}</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="vessel-name">${vessel.name || 'Unknown Vessel'}</div>
|
<div class="vessel-name">${vessel.name || 'Unknown Vessel'}</div>
|
||||||
<div class="vessel-mmsi">MMSI: ${vessel.mmsi}</div>
|
<div class="vessel-mmsi">MMSI: ${vessel.mmsi}</div>
|
||||||
@@ -676,14 +882,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = vesselArray.map(v => {
|
container.innerHTML = vesselArray.map(v => {
|
||||||
const icon = getShipIcon(v.ship_type);
|
const iconSvg = getShipIconSvg(v.ship_type, 20);
|
||||||
const category = getShipCategory(v.ship_type);
|
const category = getShipCategory(v.ship_type);
|
||||||
|
const agentBadge = v._agent ? `<span class="agent-badge">${v._agent}</span>` : '';
|
||||||
return `
|
return `
|
||||||
<div class="vessel-item ${v.mmsi === selectedMmsi ? 'selected' : ''}"
|
<div class="vessel-item ${v.mmsi === selectedMmsi ? 'selected' : ''}"
|
||||||
data-mmsi="${v.mmsi}" onclick="selectVessel('${v.mmsi}')">
|
data-mmsi="${v.mmsi}" onclick="selectVessel('${v.mmsi}')">
|
||||||
<div class="vessel-item-icon">${icon}</div>
|
<div class="vessel-item-icon">${iconSvg}</div>
|
||||||
<div class="vessel-item-info">
|
<div class="vessel-item-info">
|
||||||
<div class="vessel-item-name">${v.name || 'Unknown'}</div>
|
<div class="vessel-item-name">${v.name || 'Unknown'}${agentBadge}</div>
|
||||||
<div class="vessel-item-type">${category} | ${v.mmsi}</div>
|
<div class="vessel-item-type">${category} | ${v.mmsi}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vessel-item-speed">${v.speed ? v.speed + ' kt' : '-'}</div>
|
<div class="vessel-item-speed">${v.speed ? v.speed + ' kt' : '-'}</div>
|
||||||
@@ -828,33 +1035,51 @@
|
|||||||
const device = document.getElementById('dscDeviceSelect').value;
|
const device = document.getElementById('dscDeviceSelect').value;
|
||||||
const gain = document.getElementById('dscGain').value;
|
const gain = document.getElementById('dscGain').value;
|
||||||
|
|
||||||
fetch('/dsc/start', {
|
// Check if using agent mode
|
||||||
|
const isAgentMode = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
||||||
|
dscCurrentAgent = isAgentMode ? aisCurrentAgent : null;
|
||||||
|
|
||||||
|
// Determine endpoint based on agent mode
|
||||||
|
const endpoint = isAgentMode
|
||||||
|
? `/controller/agents/${aisCurrentAgent}/dsc/start`
|
||||||
|
: '/dsc/start';
|
||||||
|
|
||||||
|
fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ device, gain })
|
body: JSON.stringify({ device, gain })
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'started') {
|
// Handle controller proxy response format
|
||||||
|
const scanResult = isAgentMode && data.result ? data.result : data;
|
||||||
|
|
||||||
|
if (scanResult.status === 'started' || scanResult.status === 'success') {
|
||||||
isDscTracking = true;
|
isDscTracking = true;
|
||||||
document.getElementById('dscStartBtn').textContent = 'STOP DSC';
|
document.getElementById('dscStartBtn').textContent = 'STOP DSC';
|
||||||
document.getElementById('dscStartBtn').classList.add('active');
|
document.getElementById('dscStartBtn').classList.add('active');
|
||||||
document.getElementById('dscIndicator').classList.add('active');
|
document.getElementById('dscIndicator').classList.add('active');
|
||||||
startDscSSE();
|
startDscSSE(isAgentMode);
|
||||||
} else if (data.error_type === 'DEVICE_BUSY') {
|
} else if (scanResult.error_type === 'DEVICE_BUSY') {
|
||||||
alert('SDR device is busy.\n\n' + data.suggestion);
|
alert('SDR device is busy.\n\n' + (scanResult.suggestion || ''));
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || 'Failed to start DSC');
|
alert(scanResult.message || scanResult.error || 'Failed to start DSC');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => alert('Error: ' + err.message));
|
.catch(err => alert('Error: ' + err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopDscTracking() {
|
function stopDscTracking() {
|
||||||
fetch('/dsc/stop', { method: 'POST' })
|
const isAgentMode = dscCurrentAgent !== null;
|
||||||
|
const endpoint = isAgentMode
|
||||||
|
? `/controller/agents/${dscCurrentAgent}/dsc/stop`
|
||||||
|
: '/dsc/stop';
|
||||||
|
|
||||||
|
fetch(endpoint, { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
isDscTracking = false;
|
isDscTracking = false;
|
||||||
|
dscCurrentAgent = null;
|
||||||
document.getElementById('dscStartBtn').textContent = 'START DSC';
|
document.getElementById('dscStartBtn').textContent = 'START DSC';
|
||||||
document.getElementById('dscStartBtn').classList.remove('active');
|
document.getElementById('dscStartBtn').classList.remove('active');
|
||||||
document.getElementById('dscIndicator').classList.remove('active');
|
document.getElementById('dscIndicator').classList.remove('active');
|
||||||
@@ -862,23 +1087,50 @@
|
|||||||
dscEventSource.close();
|
dscEventSource.close();
|
||||||
dscEventSource = null;
|
dscEventSource = null;
|
||||||
}
|
}
|
||||||
|
// Clear polling timer
|
||||||
|
if (dscPollTimer) {
|
||||||
|
clearInterval(dscPollTimer);
|
||||||
|
dscPollTimer = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startDscSSE() {
|
function startDscSSE(isAgentMode = false) {
|
||||||
if (dscEventSource) dscEventSource.close();
|
if (dscEventSource) dscEventSource.close();
|
||||||
|
|
||||||
dscEventSource = new EventSource('/dsc/stream');
|
// Use different stream endpoint for agent mode
|
||||||
|
const streamUrl = isAgentMode ? '/controller/stream/all' : '/dsc/stream';
|
||||||
|
dscEventSource = new EventSource(streamUrl);
|
||||||
|
|
||||||
dscEventSource.onmessage = function(e) {
|
dscEventSource.onmessage = function(e) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'dsc_message') {
|
|
||||||
handleDscMessage(data);
|
if (isAgentMode) {
|
||||||
} else if (data.type === 'error') {
|
// Handle multi-agent stream format
|
||||||
console.error('DSC error:', data.error);
|
if (data.scan_type === 'dsc' && data.payload) {
|
||||||
if (data.error_type === 'DEVICE_BUSY') {
|
const payload = data.payload;
|
||||||
alert('DSC: Device became busy. ' + (data.suggestion || ''));
|
if (payload.type === 'dsc_message') {
|
||||||
stopDscTracking();
|
payload.agent_name = data.agent_name;
|
||||||
|
handleDscMessage(payload);
|
||||||
|
} else if (payload.type === 'error') {
|
||||||
|
console.error('DSC error:', payload.error);
|
||||||
|
if (payload.error_type === 'DEVICE_BUSY') {
|
||||||
|
alert('DSC: Device became busy. ' + (payload.suggestion || ''));
|
||||||
|
stopDscTracking();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Local stream format
|
||||||
|
if (data.type === 'dsc_message') {
|
||||||
|
handleDscMessage(data);
|
||||||
|
} else if (data.type === 'error') {
|
||||||
|
console.error('DSC error:', data.error);
|
||||||
|
if (data.error_type === 'DEVICE_BUSY') {
|
||||||
|
alert('DSC: Device became busy. ' + (data.suggestion || ''));
|
||||||
|
stopDscTracking();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
@@ -886,9 +1138,56 @@
|
|||||||
|
|
||||||
dscEventSource.onerror = function() {
|
dscEventSource.onerror = function() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isDscTracking) startDscSSE();
|
if (isDscTracking) startDscSSE(isAgentMode);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Start polling fallback for agent mode
|
||||||
|
if (isAgentMode) {
|
||||||
|
startDscPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track last DSC message count for polling
|
||||||
|
let lastDscMessageCount = 0;
|
||||||
|
|
||||||
|
function startDscPolling() {
|
||||||
|
if (dscPollTimer) return;
|
||||||
|
lastDscMessageCount = 0;
|
||||||
|
|
||||||
|
const pollInterval = 2000;
|
||||||
|
dscPollTimer = setInterval(async () => {
|
||||||
|
if (!isDscTracking || !dscCurrentAgent) {
|
||||||
|
clearInterval(dscPollTimer);
|
||||||
|
dscPollTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/controller/agents/${dscCurrentAgent}/dsc/data`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const result = data.result || data;
|
||||||
|
const messages = result.data || [];
|
||||||
|
|
||||||
|
// Process new messages
|
||||||
|
if (messages.length > lastDscMessageCount) {
|
||||||
|
const newMessages = messages.slice(lastDscMessageCount);
|
||||||
|
newMessages.forEach(msg => {
|
||||||
|
const dscMsg = {
|
||||||
|
type: 'dsc_message',
|
||||||
|
...msg,
|
||||||
|
agent_name: result.agent_name || 'Remote Agent'
|
||||||
|
};
|
||||||
|
handleDscMessage(dscMsg);
|
||||||
|
});
|
||||||
|
lastDscMessageCount = messages.length;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DSC polling error:', err);
|
||||||
|
}
|
||||||
|
}, pollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDscMessage(data) {
|
function handleDscMessage(data) {
|
||||||
@@ -1047,5 +1346,324 @@
|
|||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', initMap);
|
document.addEventListener('DOMContentLoaded', initMap);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Agent styles -->
|
||||||
|
<style>
|
||||||
|
.agent-selector-compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
.agent-select-sm {
|
||||||
|
background: rgba(0, 40, 60, 0.8);
|
||||||
|
border: 1px solid var(--border-color, rgba(0, 200, 255, 0.3));
|
||||||
|
color: var(--text-primary, #e0f7ff);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.agent-select-sm:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
.agent-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.agent-status-dot.online {
|
||||||
|
background: #4caf50;
|
||||||
|
box-shadow: 0 0 6px #4caf50;
|
||||||
|
}
|
||||||
|
.agent-status-dot.offline {
|
||||||
|
background: #f44336;
|
||||||
|
box-shadow: 0 0 6px #f44336;
|
||||||
|
}
|
||||||
|
.vessel-item .agent-badge {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
background: rgba(0, 200, 255, 0.1);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
#agentModeWarning {
|
||||||
|
color: #f0ad4e;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(240,173,78,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.show-all-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted, #a0c4d0);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.show-all-label input {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Agent Manager -->
|
||||||
|
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
// AIS-specific agent integration
|
||||||
|
let aisCurrentAgent = 'local';
|
||||||
|
|
||||||
|
function selectAisAgent(agentId) {
|
||||||
|
aisCurrentAgent = agentId;
|
||||||
|
currentAgent = agentId; // Update global agent state
|
||||||
|
|
||||||
|
if (agentId === 'local') {
|
||||||
|
loadDevices();
|
||||||
|
console.log('AIS: Using local device');
|
||||||
|
} else {
|
||||||
|
refreshAgentDevicesForAis(agentId);
|
||||||
|
syncAgentModeStates(agentId);
|
||||||
|
console.log(`AIS: Using agent ${agentId}`);
|
||||||
|
}
|
||||||
|
updateAgentStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAgentDevicesForAis(agentId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/controller/agents/${agentId}?refresh=true`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.agent && data.agent.interfaces) {
|
||||||
|
const devices = data.agent.interfaces.devices || [];
|
||||||
|
populateAisDeviceSelects(devices);
|
||||||
|
|
||||||
|
// Update observer location if agent has GPS
|
||||||
|
if (data.agent.gps_coords) {
|
||||||
|
const gps = typeof data.agent.gps_coords === 'string'
|
||||||
|
? JSON.parse(data.agent.gps_coords)
|
||||||
|
: data.agent.gps_coords;
|
||||||
|
if (gps.lat && gps.lon) {
|
||||||
|
document.getElementById('obsLat').value = gps.lat.toFixed(4);
|
||||||
|
document.getElementById('obsLon').value = gps.lon.toFixed(4);
|
||||||
|
updateObserverLoc();
|
||||||
|
console.log(`Updated observer location from agent GPS: ${gps.lat}, ${gps.lon}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh agent devices:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateAisDeviceSelects(devices) {
|
||||||
|
const aisSelect = document.getElementById('aisDeviceSelect');
|
||||||
|
const dscSelect = document.getElementById('dscDeviceSelect');
|
||||||
|
|
||||||
|
[aisSelect, dscSelect].forEach(select => {
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
select.innerHTML = '<option value="0">No SDR found</option>';
|
||||||
|
} else {
|
||||||
|
devices.forEach(device => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = device.index;
|
||||||
|
opt.textContent = `Device ${device.index}: ${device.name || device.type || 'SDR'}`;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override startTracking for agent support
|
||||||
|
const originalStartTracking = startTracking;
|
||||||
|
startTracking = function() {
|
||||||
|
const useAgent = aisCurrentAgent !== 'local';
|
||||||
|
|
||||||
|
if (useAgent) {
|
||||||
|
// Check for conflicts
|
||||||
|
if (typeof checkAgentModeConflict === 'function' && !checkAgentModeConflict('ais')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = document.getElementById('aisDeviceSelect').value;
|
||||||
|
const gain = document.getElementById('aisGain').value;
|
||||||
|
|
||||||
|
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ device, gain })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
// Handle controller proxy response (agent response is nested in 'result')
|
||||||
|
const scanResult = data.result || data;
|
||||||
|
if (scanResult.status === 'started' || scanResult.status === 'already_running' || scanResult.status === 'success') {
|
||||||
|
isTracking = true;
|
||||||
|
document.getElementById('startBtn').textContent = 'STOP';
|
||||||
|
document.getElementById('startBtn').classList.add('active');
|
||||||
|
document.getElementById('trackingDot').classList.add('active');
|
||||||
|
document.getElementById('trackingStatus').textContent = 'TRACKING (AGENT)';
|
||||||
|
document.getElementById('agentSelect').disabled = true;
|
||||||
|
startSessionTimer();
|
||||||
|
startSSE(); // Use multi-agent stream
|
||||||
|
startAisPolling(); // Also start polling as fallback
|
||||||
|
|
||||||
|
if (typeof agentRunningModes !== 'undefined' && !agentRunningModes.includes('ais')) {
|
||||||
|
agentRunningModes.push('ais');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(scanResult.message || 'Failed to start');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => alert('Error: ' + err.message));
|
||||||
|
} else {
|
||||||
|
originalStartTracking();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override stopTracking for agent support
|
||||||
|
const originalStopTracking = stopTracking;
|
||||||
|
stopTracking = function() {
|
||||||
|
const useAgent = aisCurrentAgent !== 'local';
|
||||||
|
|
||||||
|
if (useAgent) {
|
||||||
|
fetch(`/controller/agents/${aisCurrentAgent}/ais/stop`, { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
isTracking = false;
|
||||||
|
document.getElementById('startBtn').textContent = 'START';
|
||||||
|
document.getElementById('startBtn').classList.remove('active');
|
||||||
|
document.getElementById('trackingDot').classList.remove('active');
|
||||||
|
document.getElementById('trackingStatus').textContent = 'STANDBY';
|
||||||
|
document.getElementById('agentSelect').disabled = false;
|
||||||
|
stopSSE();
|
||||||
|
|
||||||
|
if (typeof agentRunningModes !== 'undefined') {
|
||||||
|
agentRunningModes = agentRunningModes.filter(m => m !== 'ais');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Stop error:', err));
|
||||||
|
} else {
|
||||||
|
originalStopTracking();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook into page init
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const agentSelect = document.getElementById('agentSelect');
|
||||||
|
if (agentSelect) {
|
||||||
|
agentSelect.addEventListener('change', function(e) {
|
||||||
|
selectAisAgent(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show All Agents mode - display vessels from all agents on the map
|
||||||
|
let showAllAgentsMode = false;
|
||||||
|
let allAgentsEventSource = null;
|
||||||
|
|
||||||
|
function toggleShowAllAgents() {
|
||||||
|
const checkbox = document.getElementById('showAllAgents');
|
||||||
|
showAllAgentsMode = checkbox ? checkbox.checked : false;
|
||||||
|
|
||||||
|
const agentSelect = document.getElementById('agentSelect');
|
||||||
|
const startBtn = document.getElementById('startBtn');
|
||||||
|
|
||||||
|
if (showAllAgentsMode) {
|
||||||
|
// Disable individual agent selection and start button
|
||||||
|
if (agentSelect) agentSelect.disabled = true;
|
||||||
|
if (startBtn) startBtn.disabled = true;
|
||||||
|
|
||||||
|
// Connect to multi-agent stream (passive listening to all agents)
|
||||||
|
startAllAgentsStream();
|
||||||
|
|
||||||
|
document.getElementById('trackingStatus').textContent = 'ALL AGENTS';
|
||||||
|
document.getElementById('trackingDot').classList.add('active');
|
||||||
|
console.log('Show All Agents mode enabled');
|
||||||
|
} else {
|
||||||
|
// Re-enable controls
|
||||||
|
if (agentSelect) agentSelect.disabled = isTracking;
|
||||||
|
if (startBtn) startBtn.disabled = false;
|
||||||
|
|
||||||
|
// Stop multi-agent stream
|
||||||
|
stopAllAgentsStream();
|
||||||
|
|
||||||
|
if (!isTracking) {
|
||||||
|
document.getElementById('trackingStatus').textContent = 'STANDBY';
|
||||||
|
document.getElementById('trackingDot').classList.remove('active');
|
||||||
|
}
|
||||||
|
console.log('Show All Agents mode disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAllAgentsStream() {
|
||||||
|
if (allAgentsEventSource) allAgentsEventSource.close();
|
||||||
|
|
||||||
|
allAgentsEventSource = new EventSource('/controller/stream/all');
|
||||||
|
allAgentsEventSource.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'keepalive') return;
|
||||||
|
|
||||||
|
// Handle AIS data from any agent
|
||||||
|
if (data.scan_type === 'ais' && data.payload) {
|
||||||
|
const payload = data.payload;
|
||||||
|
if (payload.vessels) {
|
||||||
|
Object.values(payload.vessels).forEach(v => {
|
||||||
|
v._agent = data.agent_name;
|
||||||
|
updateVessel({ type: 'vessel', ...v });
|
||||||
|
});
|
||||||
|
} else if (payload.mmsi) {
|
||||||
|
payload._agent = data.agent_name;
|
||||||
|
updateVessel({ type: 'vessel', ...payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle DSC data from any agent
|
||||||
|
if (data.scan_type === 'dsc' && data.payload) {
|
||||||
|
const payload = data.payload;
|
||||||
|
if (payload.messages) {
|
||||||
|
payload.messages.forEach(msg => {
|
||||||
|
msg._agent = data.agent_name;
|
||||||
|
processDscMessage(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('All agents stream parse error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
allAgentsEventSource.onerror = function() {
|
||||||
|
console.error('All agents stream error');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (showAllAgentsMode) startAllAgentsStream();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAllAgentsStream() {
|
||||||
|
if (allAgentsEventSource) {
|
||||||
|
allAgentsEventSource.close();
|
||||||
|
allAgentsEventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process DSC message (wrapper for addDscMessage if it exists)
|
||||||
|
function processDscMessage(msg) {
|
||||||
|
if (typeof addDscMessage === 'function') {
|
||||||
|
addDscMessage(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<script>
|
||||||
|
// Apply animations preference immediately to prevent flash
|
||||||
|
(function() {
|
||||||
|
var animations = localStorage.getItem('intercept-animations');
|
||||||
|
if (animations === 'off') {
|
||||||
|
document.documentElement.setAttribute('data-animations', 'off');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>iNTERCEPT // Restricted Access</title>
|
<title>iNTERCEPT // Restricted Access</title>
|
||||||
@@ -10,6 +19,31 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="landing-overlay">
|
<div class="landing-overlay">
|
||||||
|
<!-- Spinning Globe Background -->
|
||||||
|
<div class="globe-background">
|
||||||
|
<svg class="globe-svg" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Outer circle -->
|
||||||
|
<circle cx="200" cy="200" r="180" fill="none" stroke="currentColor" stroke-width="0.5"/>
|
||||||
|
<!-- Equator -->
|
||||||
|
<ellipse cx="200" cy="200" rx="180" ry="40" fill="none" stroke="currentColor" stroke-width="0.5"/>
|
||||||
|
<!-- Latitude lines -->
|
||||||
|
<ellipse cx="200" cy="140" rx="145" ry="30" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||||||
|
<ellipse cx="200" cy="260" rx="145" ry="30" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||||||
|
<ellipse cx="200" cy="90" rx="85" ry="15" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||||||
|
<ellipse cx="200" cy="310" rx="85" ry="15" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||||||
|
<!-- Prime meridian -->
|
||||||
|
<ellipse cx="200" cy="200" rx="40" ry="180" fill="none" stroke="currentColor" stroke-width="0.5" class="meridian meridian-1"/>
|
||||||
|
<!-- Additional meridians -->
|
||||||
|
<ellipse cx="200" cy="200" rx="100" ry="180" fill="none" stroke="currentColor" stroke-width="0.3" class="meridian meridian-2"/>
|
||||||
|
<ellipse cx="200" cy="200" rx="150" ry="180" fill="none" stroke="currentColor" stroke-width="0.3" class="meridian meridian-3"/>
|
||||||
|
<!-- Rotating meridian group -->
|
||||||
|
<g class="rotating-meridians">
|
||||||
|
<ellipse cx="200" cy="200" rx="70" ry="180" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||||||
|
<ellipse cx="200" cy="200" rx="130" ry="180" fill="none" stroke="currentColor" stroke-width="0.3"/>
|
||||||
|
<ellipse cx="200" cy="200" rx="170" ry="180" fill="none" stroke="currentColor" stroke-width="0.2"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div class="landing-scanline"></div>
|
<div class="landing-scanline"></div>
|
||||||
|
|
||||||
<div class="landing-content">
|
<div class="landing-content">
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
<!-- Populated by JavaScript with capability warnings -->
|
<!-- Populated by JavaScript with capability warnings -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Show All Agents option (visible when agents are available) -->
|
||||||
|
<div id="btShowAllAgentsContainer" class="section" style="display: none; padding: 8px;">
|
||||||
|
<label class="inline-checkbox" style="font-size: 10px;">
|
||||||
|
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
|
||||||
|
Show devices from all agents
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Scanner Configuration</h3>
|
<h3>Scanner Configuration</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<!-- MESHTASTIC MODE -->
|
||||||
|
<div id="meshtasticMode" class="mode-content mesh-sidebar-collapsed">
|
||||||
|
<!-- Hide Sidebar Button -->
|
||||||
|
<button class="mesh-hide-sidebar-btn" onclick="Meshtastic.toggleSidebar()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="11 17 6 12 11 7"/>
|
||||||
|
<polyline points="18 17 13 12 18 7"/>
|
||||||
|
</svg>
|
||||||
|
Hide Sidebar
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Collapse Toggle for Options Panel -->
|
||||||
|
<div class="mesh-sidebar-toggle" onclick="Meshtastic.toggleOptionsPanel()">
|
||||||
|
<span class="mesh-sidebar-toggle-icon" id="meshSidebarIcon">▶</span>
|
||||||
|
<span class="mesh-sidebar-toggle-text">Meshtastic Options</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsible Content -->
|
||||||
|
<div class="mesh-sidebar-content" id="meshSidebarContent">
|
||||||
|
<!-- Channels Panel - shown when connected -->
|
||||||
|
<div class="section" id="meshChannelsSection" style="display: none;">
|
||||||
|
<h3>Channels</h3>
|
||||||
|
<div id="meshChannelsList">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
<button class="preset-btn" onclick="Meshtastic.refreshChannels()" style="width: 100%; margin-top: 8px;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 6px; vertical-align: middle;">
|
||||||
|
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
||||||
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||||
|
</svg>
|
||||||
|
Refresh Channels
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Help</h3>
|
||||||
|
<button class="preset-btn" onclick="Meshtastic.showHelp()" style="width: 100%;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 6px; vertical-align: middle;">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
About Meshtastic
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Resources</h3>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
|
<a href="https://meshtastic.org" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
Meshtastic.org
|
||||||
|
</a>
|
||||||
|
<a href="https://meshtastic.org/docs/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Channel Configuration Modal -->
|
||||||
|
<div id="meshChannelModal" class="signal-details-modal">
|
||||||
|
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeChannelModal()"></div>
|
||||||
|
<div class="signal-details-modal-content">
|
||||||
|
<div class="signal-details-modal-header">
|
||||||
|
<h3>Configure Channel <span id="meshModalChannelIndex">0</span></h3>
|
||||||
|
<button class="signal-details-modal-close" onclick="Meshtastic.closeChannelModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="signal-details-modal-body">
|
||||||
|
<div class="signal-details-section">
|
||||||
|
<div class="signal-details-title">Channel Settings</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 12px;">
|
||||||
|
<label style="font-size: 11px; color: var(--text-secondary); margin-bottom: 4px;">Channel Name (max 12 chars)</label>
|
||||||
|
<input type="text" id="meshModalChannelName" maxlength="12" placeholder="MyChannel" style="width: 100%;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 12px;">
|
||||||
|
<label style="font-size: 11px; color: var(--text-secondary); margin-bottom: 4px;">Encryption (PSK)</label>
|
||||||
|
<select id="meshModalPskFormat" onchange="Meshtastic.onPskFormatChange()" style="width: 100%; margin-bottom: 8px;">
|
||||||
|
<option value="keep">Keep Current</option>
|
||||||
|
<option value="none">None (No Encryption)</option>
|
||||||
|
<option value="default">Default (Public Key - NOT SECURE)</option>
|
||||||
|
<option value="random">Random (Generate AES-256)</option>
|
||||||
|
<option value="simple">Passphrase (simple:...)</option>
|
||||||
|
<option value="base64">Base64 Key</option>
|
||||||
|
<option value="hex">Hex Key (0x...)</option>
|
||||||
|
</select>
|
||||||
|
<div id="meshModalPskInputContainer" style="display: none;">
|
||||||
|
<input type="text" id="meshModalPskValue" placeholder="Enter key..." style="width: 100%;">
|
||||||
|
</div>
|
||||||
|
<div id="meshModalPskWarning" style="display: none; background: rgba(255,193,7,0.1); border: 1px solid var(--accent-yellow); border-radius: 4px; padding: 8px; margin-top: 8px; font-size: 10px;">
|
||||||
|
<strong style="color: var(--accent-yellow);">Warning:</strong>
|
||||||
|
<span style="color: var(--text-secondary);">The default key is publicly known and provides no security.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-details-modal-footer" style="display: flex; gap: 8px;">
|
||||||
|
<button class="preset-btn" onclick="Meshtastic.closeChannelModal()" style="flex: 1;">Cancel</button>
|
||||||
|
<button class="run-btn" onclick="Meshtastic.saveChannelConfig()" style="flex: 1;">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -11,6 +11,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="wifiCapabilityStatus" class="info-text" style="margin-top: 8px; font-size: 10px;"></div>
|
<div id="wifiCapabilityStatus" class="info-text" style="margin-top: 8px; font-size: 10px;"></div>
|
||||||
|
<!-- Show All Agents option (visible when agents are available) -->
|
||||||
|
<div id="wifiShowAllAgentsContainer" style="margin-top: 8px; display: none;">
|
||||||
|
<label class="inline-checkbox" style="font-size: 10px;">
|
||||||
|
<input type="checkbox" id="wifiShowAllAgents" onchange="if(typeof WiFiMode !== 'undefined') WiFiMode.toggleShowAllAgents(this.checked)">
|
||||||
|
Show networks from all agents
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<!-- Settings Modal -->
|
||||||
|
<div id="settingsModal" class="settings-modal" onclick="if(event.target === this) hideSettings()">
|
||||||
|
<div class="settings-content">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>
|
||||||
|
<span class="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"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
|
Settings
|
||||||
|
</h2>
|
||||||
|
<button class="settings-close" onclick="hideSettings()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-tabs">
|
||||||
|
<button class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')">Offline</button>
|
||||||
|
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
||||||
|
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Offline Section -->
|
||||||
|
<div id="settings-offline" class="settings-section active">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Offline Mode</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Enable Offline Mode</span>
|
||||||
|
<span class="settings-label-desc">Use local assets instead of CDN</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="offlineEnabled" onchange="Settings.toggleOfflineMode(this.checked)">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Asset Sources</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">JavaScript/CSS Libraries</span>
|
||||||
|
<span class="settings-label-desc">Leaflet, Chart.js</span>
|
||||||
|
</div>
|
||||||
|
<select id="assetsSource" class="settings-select" onchange="Settings.setAssetSource(this.value)">
|
||||||
|
<option value="cdn">CDN (Online)</option>
|
||||||
|
<option value="local">Local</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Web Fonts</span>
|
||||||
|
<span class="settings-label-desc">Inter, JetBrains Mono</span>
|
||||||
|
</div>
|
||||||
|
<select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)">
|
||||||
|
<option value="cdn">Google Fonts (Online)</option>
|
||||||
|
<option value="local">Local</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Map Tiles</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Tile Provider</span>
|
||||||
|
<span class="settings-label-desc">Map background imagery</span>
|
||||||
|
</div>
|
||||||
|
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
|
||||||
|
<option value="openstreetmap">OpenStreetMap</option>
|
||||||
|
<option value="cartodb_dark">CartoDB Dark</option>
|
||||||
|
<option value="cartodb_light">CartoDB Positron</option>
|
||||||
|
<option value="esri_world">ESRI World Imagery</option>
|
||||||
|
<option value="custom">Custom URL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row custom-url-row" id="customTileUrlRow" style="display: none;">
|
||||||
|
<div class="settings-label" style="width: 100%;">
|
||||||
|
<span class="settings-label-text">Custom Tile URL</span>
|
||||||
|
<span class="settings-label-desc">e.g., http://localhost:8080/{z}/{x}/{y}.png</span>
|
||||||
|
<input type="text" id="customTileUrl" class="settings-input"
|
||||||
|
placeholder="http://tile-server/{z}/{x}/{y}.png"
|
||||||
|
onchange="Settings.setCustomTileUrl(this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Local Asset Status</div>
|
||||||
|
<div class="asset-status" id="assetStatus">
|
||||||
|
<div class="asset-status-row">
|
||||||
|
<span class="asset-name">Leaflet JS/CSS</span>
|
||||||
|
<span class="asset-badge checking" id="statusLeaflet">Checking...</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-status-row">
|
||||||
|
<span class="asset-name">Chart.js</span>
|
||||||
|
<span class="asset-badge checking" id="statusChartjs">Checking...</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-status-row">
|
||||||
|
<span class="asset-name">Inter Font</span>
|
||||||
|
<span class="asset-badge checking" id="statusInter">Checking...</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-status-row">
|
||||||
|
<span class="asset-name">JetBrains Mono</span>
|
||||||
|
<span class="asset-badge checking" id="statusJetBrains">Checking...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="check-assets-btn" onclick="Settings.checkAssets()">
|
||||||
|
Check Assets
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-info">
|
||||||
|
<strong>Note:</strong> Changes to asset sources require a page reload to take effect.
|
||||||
|
Local assets must be available in <code>/static/vendor/</code>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display Section -->
|
||||||
|
<div id="settings-display" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Visual Preferences</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Theme</span>
|
||||||
|
<span class="settings-label-desc">Color scheme preference</span>
|
||||||
|
</div>
|
||||||
|
<select id="themeSelect" class="settings-select" onchange="setThemePreference(this.value)">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Animations</span>
|
||||||
|
<span class="settings-label-desc">Enable visual effects and animations</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="animationsEnabled" checked onchange="setAnimationsEnabled(this.checked)">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About Section -->
|
||||||
|
<div id="settings-about" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="about-info">
|
||||||
|
<p><strong>iNTERCEPT</strong> - Signal Intelligence Platform</p>
|
||||||
|
<p>Version: <span class="about-version">{{ version }}</span></p>
|
||||||
|
<p>
|
||||||
|
A unified web interface for software-defined radio (SDR) tools,
|
||||||
|
supporting pager decoding, sensor monitoring, aircraft tracking,
|
||||||
|
WiFi/Bluetooth scanning, and more.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/intercept" target="_blank">GitHub Repository</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,9 +4,20 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SATELLITE COMMAND // iNTERCEPT - See the Invisible</title>
|
<title>SATELLITE COMMAND // iNTERCEPT - See the Invisible</title>
|
||||||
|
<!-- Fonts - Conditional CDN/Local loading -->
|
||||||
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
||||||
|
{% if offline_settings.assets_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||||
|
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||||
|
{% else %}
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
{% endif %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
||||||
</head>
|
</head>
|
||||||
@@ -38,6 +49,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
|
<!-- Location Source Selector -->
|
||||||
|
<div class="location-selector" id="locationSection">
|
||||||
|
<span class="location-label">Location:</span>
|
||||||
|
<select id="locationSource" class="location-select" title="Select observer location">
|
||||||
|
<option value="local">Local (This Device)</option>
|
||||||
|
</select>
|
||||||
|
<span class="location-status-dot online" id="locationStatusDot"></span>
|
||||||
|
</div>
|
||||||
<div class="status-item">
|
<div class="status-item">
|
||||||
<div class="status-dot" id="trackingDot"></div>
|
<div class="status-dot" id="trackingDot"></div>
|
||||||
<span id="trackingStatus">TRACKING</span>
|
<span id="trackingStatus">TRACKING</span>
|
||||||
@@ -183,6 +202,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Location selector styles */
|
||||||
|
.location-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
.location-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #8899aa);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.location-select {
|
||||||
|
background: rgba(0, 40, 60, 0.8);
|
||||||
|
border: 1px solid rgba(0, 200, 255, 0.3);
|
||||||
|
color: #e0f7ff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
.location-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00d4ff;
|
||||||
|
}
|
||||||
|
.location-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.location-status-dot.online {
|
||||||
|
background: #00ff88;
|
||||||
|
box-shadow: 0 0 6px #00ff88;
|
||||||
|
}
|
||||||
|
.location-status-dot.offline {
|
||||||
|
background: #ff4444;
|
||||||
|
box-shadow: 0 0 6px #ff4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<script>
|
<script>
|
||||||
// Check if embedded mode
|
// Check if embedded mode
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -197,6 +259,8 @@
|
|||||||
let observerMarker = null;
|
let observerMarker = null;
|
||||||
let orbitTrack = null;
|
let orbitTrack = null;
|
||||||
let selectedSatellite = 25544;
|
let selectedSatellite = 25544;
|
||||||
|
let currentLocationSource = 'local';
|
||||||
|
let agents = [];
|
||||||
|
|
||||||
const satellites = {
|
const satellites = {
|
||||||
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
|
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
|
||||||
@@ -256,9 +320,87 @@
|
|||||||
setInterval(updateClock, 1000);
|
setInterval(updateClock, 1000);
|
||||||
setInterval(updateCountdown, 1000);
|
setInterval(updateCountdown, 1000);
|
||||||
setInterval(updateRealTimePositions, 5000);
|
setInterval(updateRealTimePositions, 5000);
|
||||||
|
loadAgents();
|
||||||
getLocation();
|
getLocation();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadAgents() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/controller/agents');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status === 'success' && data.agents) {
|
||||||
|
agents = data.agents;
|
||||||
|
populateLocationSelector();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('No agents available (controller not running)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateLocationSelector() {
|
||||||
|
const select = document.getElementById('locationSource');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
// Keep local option, add agents with GPS
|
||||||
|
agents.forEach(agent => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = 'agent-' + agent.id;
|
||||||
|
option.textContent = agent.name;
|
||||||
|
if (agent.gps_coords) {
|
||||||
|
option.textContent += ' (GPS)';
|
||||||
|
}
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
select.addEventListener('change', onLocationSourceChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLocationSourceChange() {
|
||||||
|
const select = document.getElementById('locationSource');
|
||||||
|
const value = select.value;
|
||||||
|
currentLocationSource = value;
|
||||||
|
|
||||||
|
const statusDot = document.getElementById('locationStatusDot');
|
||||||
|
|
||||||
|
if (value === 'local') {
|
||||||
|
// Use local GPS
|
||||||
|
statusDot.className = 'location-status-dot online';
|
||||||
|
getLocation();
|
||||||
|
} else if (value.startsWith('agent-')) {
|
||||||
|
// Fetch agent's GPS position
|
||||||
|
const agentId = value.replace('agent-', '');
|
||||||
|
try {
|
||||||
|
statusDot.className = 'location-status-dot online';
|
||||||
|
const response = await fetch(`/controller/agents/${agentId}/status`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success' && data.result) {
|
||||||
|
const agentStatus = data.result;
|
||||||
|
if (agentStatus.gps_position) {
|
||||||
|
const gps = agentStatus.gps_position;
|
||||||
|
document.getElementById('obsLat').value = gps.lat.toFixed(4);
|
||||||
|
document.getElementById('obsLon').value = gps.lon.toFixed(4);
|
||||||
|
|
||||||
|
// Update observer marker label
|
||||||
|
const agent = agents.find(a => a.id == agentId);
|
||||||
|
if (agent) {
|
||||||
|
console.log(`Using GPS from agent: ${agent.name} (${gps.lat.toFixed(4)}, ${gps.lon.toFixed(4)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculatePasses();
|
||||||
|
} else {
|
||||||
|
alert('Agent does not have GPS data available');
|
||||||
|
statusDot.className = 'location-status-dot offline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get agent GPS:', err);
|
||||||
|
statusDot.className = 'location-status-dot offline';
|
||||||
|
alert('Failed to connect to agent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateClock() {
|
function updateClock() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
document.getElementById('utcTime').textContent =
|
document.getElementById('utcTime').textContent =
|
||||||
@@ -543,6 +685,16 @@
|
|||||||
|
|
||||||
if (observerMarker) groundMap.removeLayer(observerMarker);
|
if (observerMarker) groundMap.removeLayer(observerMarker);
|
||||||
|
|
||||||
|
// Determine location label
|
||||||
|
let locationLabel = 'Local Observer';
|
||||||
|
if (currentLocationSource && currentLocationSource.startsWith('agent-')) {
|
||||||
|
const agentId = currentLocationSource.replace('agent-', '');
|
||||||
|
const agent = agents.find(a => a.id == agentId);
|
||||||
|
if (agent) {
|
||||||
|
locationLabel = agent.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const obsIcon = L.divIcon({
|
const obsIcon = L.divIcon({
|
||||||
className: 'obs-marker',
|
className: 'obs-marker',
|
||||||
html: `<div style="width: 12px; height: 12px; background: #ff9500; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 15px #ff9500;"></div>`,
|
html: `<div style="width: 12px; height: 12px; background: #ff9500; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 15px #ff9500;"></div>`,
|
||||||
@@ -552,7 +704,7 @@
|
|||||||
|
|
||||||
observerMarker = L.marker([lat, lon], { icon: obsIcon })
|
observerMarker = L.marker([lat, lon], { icon: obsIcon })
|
||||||
.addTo(groundMap)
|
.addTo(groundMap)
|
||||||
.bindPopup('Observer Location');
|
.bindPopup(`<b>${locationLabel}</b><br>${lat.toFixed(4)}°, ${lon.toFixed(4)}°`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStats() {
|
function updateStats() {
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Mock Intercept Agent for development and testing.
|
||||||
|
|
||||||
|
This provides a simulated agent that generates fake data for testing
|
||||||
|
the controller without needing actual SDR hardware.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tests/mock_agent.py [--port 8021] [--name mock-agent-1]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# State
|
||||||
|
running_modes: set[str] = set()
|
||||||
|
start_time = time.time()
|
||||||
|
agent_name = "mock-agent-1"
|
||||||
|
|
||||||
|
# Simulated data generators
|
||||||
|
def generate_aircraft() -> list[dict]:
|
||||||
|
"""Generate fake ADS-B aircraft data."""
|
||||||
|
aircraft = []
|
||||||
|
for _ in range(random.randint(3, 10)):
|
||||||
|
icao = ''.join(random.choices(string.hexdigits.upper()[:6], k=6))
|
||||||
|
callsign = random.choice(['UAL', 'DAL', 'AAL', 'SWA', 'JBU']) + str(random.randint(100, 9999))
|
||||||
|
aircraft.append({
|
||||||
|
'icao': icao,
|
||||||
|
'callsign': callsign,
|
||||||
|
'altitude': random.randint(5000, 45000),
|
||||||
|
'speed': random.randint(200, 550),
|
||||||
|
'heading': random.randint(0, 359),
|
||||||
|
'lat': round(40.0 + random.uniform(-2, 2), 4),
|
||||||
|
'lon': round(-74.0 + random.uniform(-2, 2), 4),
|
||||||
|
'vertical_rate': random.randint(-2000, 2000),
|
||||||
|
'squawk': str(random.randint(1000, 7777)),
|
||||||
|
'last_seen': datetime.now(timezone.utc).isoformat()
|
||||||
|
})
|
||||||
|
return aircraft
|
||||||
|
|
||||||
|
|
||||||
|
def generate_sensors() -> list[dict]:
|
||||||
|
"""Generate fake 433MHz sensor data."""
|
||||||
|
sensors = []
|
||||||
|
models = ['Acurite-Tower', 'Oregon-THGR122N', 'LaCrosse-TX141W', 'Ambient-F007TH']
|
||||||
|
for i in range(random.randint(2, 5)):
|
||||||
|
sensors.append({
|
||||||
|
'time': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'model': random.choice(models),
|
||||||
|
'id': random.randint(1, 255),
|
||||||
|
'channel': random.randint(1, 3),
|
||||||
|
'temperature_C': round(random.uniform(-10, 35), 1),
|
||||||
|
'humidity': random.randint(20, 95),
|
||||||
|
'battery_ok': random.choice([0, 1])
|
||||||
|
})
|
||||||
|
return sensors
|
||||||
|
|
||||||
|
|
||||||
|
def generate_wifi_networks() -> list[dict]:
|
||||||
|
"""Generate fake WiFi network data."""
|
||||||
|
networks = []
|
||||||
|
ssids = ['HomeNetwork', 'Linksys', 'NETGEAR', 'xfinitywifi', 'ATT-WIFI', 'CoffeeShop-Guest']
|
||||||
|
for ssid in random.sample(ssids, random.randint(3, 6)):
|
||||||
|
bssid = ':'.join(['%02X' % random.randint(0, 255) for _ in range(6)])
|
||||||
|
networks.append({
|
||||||
|
'ssid': ssid,
|
||||||
|
'bssid': bssid,
|
||||||
|
'channel': random.choice([1, 6, 11, 36, 40, 44, 48]),
|
||||||
|
'signal': random.randint(-80, -30),
|
||||||
|
'encryption': random.choice(['WPA2', 'WPA3', 'WEP', 'Open']),
|
||||||
|
'clients': random.randint(0, 10),
|
||||||
|
'last_seen': datetime.now(timezone.utc).isoformat()
|
||||||
|
})
|
||||||
|
return networks
|
||||||
|
|
||||||
|
|
||||||
|
def generate_bluetooth_devices() -> list[dict]:
|
||||||
|
"""Generate fake Bluetooth device data."""
|
||||||
|
devices = []
|
||||||
|
names = ['iPhone', 'Galaxy S21', 'AirPods', 'Tile Tracker', 'Fitbit', 'Unknown']
|
||||||
|
for _ in range(random.randint(2, 8)):
|
||||||
|
mac = ':'.join(['%02X' % random.randint(0, 255) for _ in range(6)])
|
||||||
|
devices.append({
|
||||||
|
'address': mac,
|
||||||
|
'name': random.choice(names),
|
||||||
|
'rssi': random.randint(-90, -40),
|
||||||
|
'type': random.choice(['LE', 'Classic', 'Dual']),
|
||||||
|
'manufacturer': random.choice(['Apple', 'Samsung', 'Unknown']),
|
||||||
|
'last_seen': datetime.now(timezone.utc).isoformat()
|
||||||
|
})
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def generate_vessels() -> list[dict]:
|
||||||
|
"""Generate fake AIS vessel data."""
|
||||||
|
vessels = []
|
||||||
|
vessel_names = ['EVERGREEN', 'MAERSK WINNER', 'OOCL HONG KONG', 'MSC GULSUN', 'CMA CGM MARCO POLO']
|
||||||
|
for name in random.sample(vessel_names, random.randint(2, 4)):
|
||||||
|
mmsi = str(random.randint(200000000, 800000000))
|
||||||
|
vessels.append({
|
||||||
|
'mmsi': mmsi,
|
||||||
|
'name': name,
|
||||||
|
'callsign': ''.join(random.choices(string.ascii_uppercase, k=5)),
|
||||||
|
'ship_type': random.choice(['Cargo', 'Tanker', 'Passenger', 'Fishing']),
|
||||||
|
'lat': round(40.5 + random.uniform(-0.5, 0.5), 4),
|
||||||
|
'lon': round(-73.9 + random.uniform(-0.5, 0.5), 4),
|
||||||
|
'speed': round(random.uniform(0, 25), 1),
|
||||||
|
'course': random.randint(0, 359),
|
||||||
|
'destination': random.choice(['NEW YORK', 'NEWARK', 'BALTIMORE', 'BOSTON']),
|
||||||
|
'last_seen': datetime.now(timezone.utc).isoformat()
|
||||||
|
})
|
||||||
|
return vessels
|
||||||
|
|
||||||
|
|
||||||
|
# Data snapshot storage
|
||||||
|
data_snapshots: dict[str, list] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def update_data_snapshot(mode: str):
|
||||||
|
"""Update data snapshot for a mode."""
|
||||||
|
if mode == 'adsb':
|
||||||
|
data_snapshots[mode] = generate_aircraft()
|
||||||
|
elif mode == 'sensor':
|
||||||
|
data_snapshots[mode] = generate_sensors()
|
||||||
|
elif mode == 'wifi':
|
||||||
|
data_snapshots[mode] = generate_wifi_networks()
|
||||||
|
elif mode == 'bluetooth':
|
||||||
|
data_snapshots[mode] = generate_bluetooth_devices()
|
||||||
|
elif mode == 'ais':
|
||||||
|
data_snapshots[mode] = generate_vessels()
|
||||||
|
else:
|
||||||
|
data_snapshots[mode] = []
|
||||||
|
|
||||||
|
|
||||||
|
# Background data generation threads
|
||||||
|
data_threads: dict[str, threading.Event] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def data_generator_loop(mode: str, stop_event: threading.Event):
|
||||||
|
"""Background loop to generate data periodically."""
|
||||||
|
while not stop_event.is_set():
|
||||||
|
update_data_snapshot(mode)
|
||||||
|
stop_event.wait(random.uniform(2, 5))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Routes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.route('/capabilities')
|
||||||
|
def capabilities():
|
||||||
|
"""Return mock capabilities."""
|
||||||
|
return jsonify({
|
||||||
|
'modes': {
|
||||||
|
'pager': True,
|
||||||
|
'sensor': True,
|
||||||
|
'adsb': True,
|
||||||
|
'ais': True,
|
||||||
|
'acars': True,
|
||||||
|
'aprs': True,
|
||||||
|
'wifi': True,
|
||||||
|
'bluetooth': True,
|
||||||
|
'dsc': True,
|
||||||
|
'rtlamr': True,
|
||||||
|
'tscm': True,
|
||||||
|
'satellite': True,
|
||||||
|
'listening_post': True
|
||||||
|
},
|
||||||
|
'devices': [
|
||||||
|
{'index': 0, 'name': 'Mock RTL-SDR', 'type': 'rtlsdr', 'serial': 'MOCK001'}
|
||||||
|
],
|
||||||
|
'agent_version': '1.0.0-mock'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/status')
|
||||||
|
def status():
|
||||||
|
"""Return agent status."""
|
||||||
|
return jsonify({
|
||||||
|
'running_modes': list(running_modes),
|
||||||
|
'uptime': time.time() - start_time,
|
||||||
|
'push_enabled': False,
|
||||||
|
'push_connected': False
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health():
|
||||||
|
"""Health check."""
|
||||||
|
return jsonify({'status': 'healthy', 'version': '1.0.0-mock'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/config', methods=['GET', 'POST'])
|
||||||
|
def config():
|
||||||
|
"""Config endpoint."""
|
||||||
|
if request.method == 'POST':
|
||||||
|
return jsonify({'status': 'updated', 'config': {}})
|
||||||
|
return jsonify({
|
||||||
|
'name': agent_name,
|
||||||
|
'port': request.environ.get('SERVER_PORT', 8021),
|
||||||
|
'push_enabled': False,
|
||||||
|
'modes_enabled': {m: True for m in [
|
||||||
|
'pager', 'sensor', 'adsb', 'ais', 'wifi', 'bluetooth'
|
||||||
|
]}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/<mode>/start', methods=['POST'])
|
||||||
|
def start_mode(mode: str):
|
||||||
|
"""Start a mode."""
|
||||||
|
if mode in running_modes:
|
||||||
|
return jsonify({'status': 'error', 'message': f'{mode} already running'}), 409
|
||||||
|
|
||||||
|
running_modes.add(mode)
|
||||||
|
|
||||||
|
# Start data generation thread
|
||||||
|
stop_event = threading.Event()
|
||||||
|
data_threads[mode] = stop_event
|
||||||
|
thread = threading.Thread(target=data_generator_loop, args=(mode, stop_event))
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
# Generate initial data
|
||||||
|
update_data_snapshot(mode)
|
||||||
|
|
||||||
|
return jsonify({'status': 'started', 'mode': mode})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/<mode>/stop', methods=['POST'])
|
||||||
|
def stop_mode(mode: str):
|
||||||
|
"""Stop a mode."""
|
||||||
|
if mode not in running_modes:
|
||||||
|
return jsonify({'status': 'not_running'})
|
||||||
|
|
||||||
|
running_modes.discard(mode)
|
||||||
|
|
||||||
|
# Stop data generation thread
|
||||||
|
if mode in data_threads:
|
||||||
|
data_threads[mode].set()
|
||||||
|
del data_threads[mode]
|
||||||
|
|
||||||
|
# Clear data
|
||||||
|
if mode in data_snapshots:
|
||||||
|
del data_snapshots[mode]
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped', 'mode': mode})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/<mode>/status')
|
||||||
|
def mode_status(mode: str):
|
||||||
|
"""Get mode status."""
|
||||||
|
return jsonify({
|
||||||
|
'running': mode in running_modes,
|
||||||
|
'data_count': len(data_snapshots.get(mode, []))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/<mode>/data')
|
||||||
|
def mode_data(mode: str):
|
||||||
|
"""Get current data snapshot."""
|
||||||
|
# Generate fresh data if mode is running but no snapshot exists
|
||||||
|
if mode in running_modes and mode not in data_snapshots:
|
||||||
|
update_data_snapshot(mode)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'mode': mode,
|
||||||
|
'data': data_snapshots.get(mode, []),
|
||||||
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'agent_name': agent_name
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global agent_name, start_time
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description='Mock Intercept Agent')
|
||||||
|
parser.add_argument('--port', '-p', type=int, default=8021, help='Port (default: 8021)')
|
||||||
|
parser.add_argument('--name', '-n', default='mock-agent-1', help='Agent name')
|
||||||
|
parser.add_argument('--debug', action='store_true', help='Enable debug mode')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
agent_name = args.name
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print(" MOCK INTERCEPT AGENT")
|
||||||
|
print(" For development and testing")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print(f" Agent Name: {agent_name}")
|
||||||
|
print(f" Port: {args.port}")
|
||||||
|
print()
|
||||||
|
print(" Available modes: all (simulated data)")
|
||||||
|
print()
|
||||||
|
print(f" Listening on http://0.0.0.0:{args.port}")
|
||||||
|
print()
|
||||||
|
print(" Press Ctrl+C to stop")
|
||||||
|
print()
|
||||||
|
|
||||||
|
app.run(host='0.0.0.0', port=args.port, debug=args.debug)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
"""
|
||||||
|
Tests for Intercept Agent components.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- AgentConfig parsing
|
||||||
|
- AgentClient HTTP operations
|
||||||
|
- Database agent CRUD operations
|
||||||
|
- GPS integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from utils.agent_client import (
|
||||||
|
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
|
||||||
|
)
|
||||||
|
from utils.database import (
|
||||||
|
init_db, get_db_path, create_agent, get_agent, get_agent_by_name,
|
||||||
|
list_agents, update_agent, delete_agent, store_push_payload,
|
||||||
|
get_recent_payloads, cleanup_old_payloads
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AgentConfig Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAgentConfig:
|
||||||
|
"""Tests for AgentConfig class."""
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
"""AgentConfig should have sensible defaults."""
|
||||||
|
from intercept_agent import AgentConfig
|
||||||
|
config = AgentConfig()
|
||||||
|
|
||||||
|
assert config.port == 8020
|
||||||
|
assert config.allow_cors is False
|
||||||
|
assert config.push_enabled is False
|
||||||
|
assert config.push_interval == 5
|
||||||
|
assert config.controller_url == ''
|
||||||
|
assert 'adsb' in config.modes_enabled
|
||||||
|
assert 'wifi' in config.modes_enabled
|
||||||
|
assert config.modes_enabled['adsb'] is True
|
||||||
|
|
||||||
|
def test_load_from_file_valid(self):
|
||||||
|
"""AgentConfig should load from valid INI file."""
|
||||||
|
from intercept_agent import AgentConfig
|
||||||
|
|
||||||
|
config_content = """
|
||||||
|
[agent]
|
||||||
|
name = test-sensor
|
||||||
|
port = 8025
|
||||||
|
allowed_ips = 192.168.1.0/24, 10.0.0.1
|
||||||
|
allow_cors = true
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
url = http://192.168.1.100:5050
|
||||||
|
api_key = secret123
|
||||||
|
push_enabled = true
|
||||||
|
push_interval = 10
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
pager = false
|
||||||
|
adsb = true
|
||||||
|
wifi = true
|
||||||
|
bluetooth = false
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.cfg', delete=False) as f:
|
||||||
|
f.write(config_content)
|
||||||
|
config_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = AgentConfig()
|
||||||
|
result = config.load_from_file(config_path)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert config.name == 'test-sensor'
|
||||||
|
assert config.port == 8025
|
||||||
|
assert '192.168.1.0/24' in config.allowed_ips
|
||||||
|
assert config.allow_cors is True
|
||||||
|
assert config.controller_url == 'http://192.168.1.100:5050'
|
||||||
|
assert config.controller_api_key == 'secret123'
|
||||||
|
assert config.push_enabled is True
|
||||||
|
assert config.push_interval == 10
|
||||||
|
assert config.modes_enabled['pager'] is False
|
||||||
|
assert config.modes_enabled['adsb'] is True
|
||||||
|
assert config.modes_enabled['bluetooth'] is False
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
def test_load_from_file_missing(self):
|
||||||
|
"""AgentConfig should handle missing file gracefully."""
|
||||||
|
from intercept_agent import AgentConfig
|
||||||
|
config = AgentConfig()
|
||||||
|
result = config.load_from_file('/nonexistent/path.cfg')
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""AgentConfig should convert to dictionary."""
|
||||||
|
from intercept_agent import AgentConfig
|
||||||
|
config = AgentConfig()
|
||||||
|
config.name = 'test'
|
||||||
|
config.port = 9000
|
||||||
|
|
||||||
|
d = config.to_dict()
|
||||||
|
|
||||||
|
assert d['name'] == 'test'
|
||||||
|
assert d['port'] == 9000
|
||||||
|
assert 'modes_enabled' in d
|
||||||
|
assert isinstance(d['modes_enabled'], dict)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AgentClient Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAgentClient:
|
||||||
|
"""Tests for AgentClient HTTP operations."""
|
||||||
|
|
||||||
|
def test_init(self):
|
||||||
|
"""AgentClient should initialize correctly."""
|
||||||
|
client = AgentClient('http://192.168.1.50:8020', api_key='secret')
|
||||||
|
assert client.base_url == 'http://192.168.1.50:8020'
|
||||||
|
assert client.api_key == 'secret'
|
||||||
|
assert client.timeout == 60.0
|
||||||
|
|
||||||
|
def test_init_strips_trailing_slash(self):
|
||||||
|
"""AgentClient should strip trailing slash from URL."""
|
||||||
|
client = AgentClient('http://192.168.1.50:8020/')
|
||||||
|
assert client.base_url == 'http://192.168.1.50:8020'
|
||||||
|
|
||||||
|
def test_headers_without_api_key(self):
|
||||||
|
"""Headers should not include API key if not provided."""
|
||||||
|
client = AgentClient('http://localhost:8020')
|
||||||
|
headers = client._headers()
|
||||||
|
assert 'X-API-Key' not in headers
|
||||||
|
assert 'Content-Type' in headers
|
||||||
|
|
||||||
|
def test_headers_with_api_key(self):
|
||||||
|
"""Headers should include API key if provided."""
|
||||||
|
client = AgentClient('http://localhost:8020', api_key='test-key')
|
||||||
|
headers = client._headers()
|
||||||
|
assert headers['X-API-Key'] == 'test-key'
|
||||||
|
|
||||||
|
@patch('utils.agent_client.requests.get')
|
||||||
|
def test_get_capabilities(self, mock_get):
|
||||||
|
"""get_capabilities should parse JSON response."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
'modes': {'adsb': True, 'wifi': True},
|
||||||
|
'devices': [{'name': 'RTL-SDR'}],
|
||||||
|
'agent_version': '1.0.0'
|
||||||
|
}
|
||||||
|
mock_response.content = b'{}'
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
client = AgentClient('http://localhost:8020')
|
||||||
|
caps = client.get_capabilities()
|
||||||
|
|
||||||
|
assert caps['modes']['adsb'] is True
|
||||||
|
assert len(caps['devices']) == 1
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
|
||||||
|
@patch('utils.agent_client.requests.get')
|
||||||
|
def test_get_status(self, mock_get):
|
||||||
|
"""get_status should return status dict."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
'running_modes': ['adsb', 'sensor'],
|
||||||
|
'uptime': 3600,
|
||||||
|
'push_enabled': True
|
||||||
|
}
|
||||||
|
mock_response.content = b'{}'
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
client = AgentClient('http://localhost:8020')
|
||||||
|
status = client.get_status()
|
||||||
|
|
||||||
|
assert 'adsb' in status['running_modes']
|
||||||
|
assert status['uptime'] == 3600
|
||||||
|
|
||||||
|
@patch('utils.agent_client.requests.get')
|
||||||
|
def test_health_check_healthy(self, mock_get):
|
||||||
|
"""health_check should return True for healthy agent."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {'status': 'healthy'}
|
||||||
|
mock_response.content = b'{}'
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
client = AgentClient('http://localhost:8020')
|
||||||
|
assert client.health_check() is True
|
||||||
|
|
||||||
|
@patch('utils.agent_client.requests.get')
|
||||||
|
def test_health_check_unhealthy(self, mock_get):
|
||||||
|
"""health_check should return False for connection error."""
|
||||||
|
import requests
|
||||||
|
mock_get.side_effect = requests.ConnectionError("Connection refused")
|
||||||
|
|
||||||
|
client = AgentClient('http://localhost:8020')
|
||||||
|
assert client.health_check() is False
|
||||||
|
|
||||||
|
@patch('utils.agent_client.requests.post')
|
||||||
|
def test_start_mode(self, mock_post):
|
||||||
|
"""start_mode should POST to correct endpoint."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {'status': 'started', 'mode': 'adsb'}
|
||||||
|
mock_response.content = b'{}'
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
client = AgentClient('http://localhost:8020')
|
||||||
|
result = client.start_mode('adsb', {'device_index': 0})
|
||||||
|
|
||||||
|
assert result['status'] == 'started'
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
call_url = mock_post.call_args[0][0]
|
||||||
|
assert '/adsb/start' in call_url
|
||||||
|
|
||||||
|
@patch('utils.agent_client.requests.post')
|
||||||
|
def test_stop_mode(self, mock_post):
|
||||||
|
"""stop_mode should POST to stop endpoint."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {'status': 'stopped'}
|
||||||
|
mock_response.content = b'{}'
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
client = AgentClient('http://localhost:8020')
|
||||||
|
result = client.stop_mode('wifi')
|
||||||
|
|
||||||
|
assert result['status'] == 'stopped'
|
||||||
|
|
||||||
|
@patch('utils.agent_client.requests.get')
|
||||||
|
def test_get_mode_data(self, mock_get):
|
||||||
|
"""get_mode_data should return data snapshot."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
'mode': 'adsb',
|
||||||
|
'data': [
|
||||||
|
{'icao': 'ABC123', 'altitude': 35000},
|
||||||
|
{'icao': 'DEF456', 'altitude': 28000}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.content = b'{}'
|
||||||
|
mock_response.raise_for_status = Mock()
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
client = AgentClient('http://localhost:8020')
|
||||||
|
result = client.get_mode_data('adsb')
|
||||||
|
|
||||||
|
assert len(result['data']) == 2
|
||||||
|
assert result['data'][0]['icao'] == 'ABC123'
|
||||||
|
|
||||||
|
@patch('utils.agent_client.requests.get')
|
||||||
|
def test_connection_error_handling(self, mock_get):
|
||||||
|
"""Client should raise AgentConnectionError on connection failure."""
|
||||||
|
import requests
|
||||||
|
mock_get.side_effect = requests.ConnectionError("Connection refused")
|
||||||
|
|
||||||
|
client = AgentClient('http://localhost:8020')
|
||||||
|
|
||||||
|
with pytest.raises(AgentConnectionError) as exc_info:
|
||||||
|
client.get_capabilities()
|
||||||
|
assert 'Cannot connect' in str(exc_info.value)
|
||||||
|
|
||||||
|
@patch('utils.agent_client.requests.get')
|
||||||
|
def test_timeout_error_handling(self, mock_get):
|
||||||
|
"""Client should raise AgentConnectionError on timeout."""
|
||||||
|
import requests
|
||||||
|
mock_get.side_effect = requests.Timeout("Request timed out")
|
||||||
|
|
||||||
|
client = AgentClient('http://localhost:8020', timeout=5.0)
|
||||||
|
|
||||||
|
with pytest.raises(AgentConnectionError) as exc_info:
|
||||||
|
client.get_status()
|
||||||
|
assert 'timed out' in str(exc_info.value)
|
||||||
|
|
||||||
|
@patch('utils.agent_client.requests.get')
|
||||||
|
def test_http_error_handling(self, mock_get):
|
||||||
|
"""Client should raise AgentHTTPError on HTTP errors."""
|
||||||
|
import requests
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.raise_for_status.side_effect = requests.HTTPError(response=mock_response)
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
client = AgentClient('http://localhost:8020')
|
||||||
|
|
||||||
|
with pytest.raises(AgentHTTPError) as exc_info:
|
||||||
|
client.get_capabilities()
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
|
||||||
|
def test_create_client_from_agent(self):
|
||||||
|
"""create_client_from_agent should create configured client."""
|
||||||
|
agent = {
|
||||||
|
'id': 1,
|
||||||
|
'name': 'test-agent',
|
||||||
|
'base_url': 'http://192.168.1.50:8020',
|
||||||
|
'api_key': 'secret123'
|
||||||
|
}
|
||||||
|
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
|
||||||
|
assert client.base_url == 'http://192.168.1.50:8020'
|
||||||
|
assert client.api_key == 'secret123'
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Database Agent CRUD Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestDatabaseAgentCRUD:
|
||||||
|
"""Tests for database agent operations."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_db(self, tmp_path):
|
||||||
|
"""Set up a temporary database for each test."""
|
||||||
|
import utils.database as db_module
|
||||||
|
|
||||||
|
# Create temp database
|
||||||
|
test_db_path = tmp_path / 'test.db'
|
||||||
|
original_db_path = db_module.DB_PATH
|
||||||
|
db_module.DB_PATH = test_db_path
|
||||||
|
db_module.DB_DIR = tmp_path
|
||||||
|
|
||||||
|
# Clear any existing connection
|
||||||
|
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||||
|
db_module._local.connection.close()
|
||||||
|
db_module._local.connection = None
|
||||||
|
|
||||||
|
# Initialize schema
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||||
|
db_module._local.connection.close()
|
||||||
|
db_module._local.connection = None
|
||||||
|
db_module.DB_PATH = original_db_path
|
||||||
|
|
||||||
|
def test_create_agent(self):
|
||||||
|
"""create_agent should insert new agent."""
|
||||||
|
agent_id = create_agent(
|
||||||
|
name='sensor-1',
|
||||||
|
base_url='http://192.168.1.50:8020',
|
||||||
|
api_key='secret',
|
||||||
|
description='Test sensor node'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent_id is not None
|
||||||
|
assert agent_id > 0
|
||||||
|
|
||||||
|
def test_get_agent(self):
|
||||||
|
"""get_agent should retrieve agent by ID."""
|
||||||
|
agent_id = create_agent(
|
||||||
|
name='sensor-1',
|
||||||
|
base_url='http://192.168.1.50:8020'
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
|
||||||
|
assert agent is not None
|
||||||
|
assert agent['name'] == 'sensor-1'
|
||||||
|
assert agent['base_url'] == 'http://192.168.1.50:8020'
|
||||||
|
assert agent['is_active'] is True
|
||||||
|
|
||||||
|
def test_get_agent_not_found(self):
|
||||||
|
"""get_agent should return None for missing agent."""
|
||||||
|
agent = get_agent(99999)
|
||||||
|
assert agent is None
|
||||||
|
|
||||||
|
def test_get_agent_by_name(self):
|
||||||
|
"""get_agent_by_name should find agent by name."""
|
||||||
|
create_agent(name='unique-sensor', base_url='http://localhost:8020')
|
||||||
|
|
||||||
|
agent = get_agent_by_name('unique-sensor')
|
||||||
|
|
||||||
|
assert agent is not None
|
||||||
|
assert agent['name'] == 'unique-sensor'
|
||||||
|
|
||||||
|
def test_get_agent_by_name_not_found(self):
|
||||||
|
"""get_agent_by_name should return None for missing name."""
|
||||||
|
agent = get_agent_by_name('nonexistent-sensor')
|
||||||
|
assert agent is None
|
||||||
|
|
||||||
|
def test_list_agents(self):
|
||||||
|
"""list_agents should return all active agents."""
|
||||||
|
create_agent(name='sensor-1', base_url='http://192.168.1.51:8020')
|
||||||
|
create_agent(name='sensor-2', base_url='http://192.168.1.52:8020')
|
||||||
|
create_agent(name='sensor-3', base_url='http://192.168.1.53:8020')
|
||||||
|
|
||||||
|
agents = list_agents()
|
||||||
|
|
||||||
|
assert len(agents) >= 3
|
||||||
|
names = [a['name'] for a in agents]
|
||||||
|
assert 'sensor-1' in names
|
||||||
|
assert 'sensor-2' in names
|
||||||
|
|
||||||
|
def test_list_agents_active_only(self):
|
||||||
|
"""list_agents should filter inactive agents by default."""
|
||||||
|
agent_id = create_agent(name='inactive-sensor', base_url='http://localhost:8020')
|
||||||
|
update_agent(agent_id, is_active=False)
|
||||||
|
|
||||||
|
agents = list_agents(active_only=True)
|
||||||
|
|
||||||
|
names = [a['name'] for a in agents]
|
||||||
|
assert 'inactive-sensor' not in names
|
||||||
|
|
||||||
|
def test_update_agent(self):
|
||||||
|
"""update_agent should modify agent fields."""
|
||||||
|
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||||
|
|
||||||
|
result = update_agent(
|
||||||
|
agent_id,
|
||||||
|
base_url='http://192.168.1.100:8020',
|
||||||
|
description='Updated description'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
assert agent['base_url'] == 'http://192.168.1.100:8020'
|
||||||
|
assert agent['description'] == 'Updated description'
|
||||||
|
|
||||||
|
def test_update_agent_capabilities(self):
|
||||||
|
"""update_agent should update capabilities JSON."""
|
||||||
|
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||||
|
|
||||||
|
caps = {'adsb': True, 'wifi': True, 'bluetooth': False}
|
||||||
|
update_agent(agent_id, capabilities=caps)
|
||||||
|
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
assert agent['capabilities']['adsb'] is True
|
||||||
|
assert agent['capabilities']['bluetooth'] is False
|
||||||
|
|
||||||
|
def test_update_agent_gps_coords(self):
|
||||||
|
"""update_agent should update GPS coordinates."""
|
||||||
|
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||||
|
|
||||||
|
gps = {'lat': 40.7128, 'lon': -74.0060, 'altitude': 10}
|
||||||
|
update_agent(agent_id, gps_coords=gps)
|
||||||
|
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
assert agent['gps_coords']['lat'] == 40.7128
|
||||||
|
assert agent['gps_coords']['lon'] == -74.0060
|
||||||
|
|
||||||
|
def test_delete_agent(self):
|
||||||
|
"""delete_agent should remove agent and payloads."""
|
||||||
|
agent_id = create_agent(name='to-delete', base_url='http://localhost:8020')
|
||||||
|
|
||||||
|
# Add a payload
|
||||||
|
store_push_payload(agent_id, 'adsb', {'aircraft': []})
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
result = delete_agent(agent_id)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert get_agent(agent_id) is None
|
||||||
|
|
||||||
|
def test_delete_agent_not_found(self):
|
||||||
|
"""delete_agent should return False for missing agent."""
|
||||||
|
result = delete_agent(99999)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Database Push Payload Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestDatabasePayloads:
|
||||||
|
"""Tests for push payload storage."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_db(self, tmp_path):
|
||||||
|
"""Set up a temporary database for each test."""
|
||||||
|
import utils.database as db_module
|
||||||
|
|
||||||
|
test_db_path = tmp_path / 'test.db'
|
||||||
|
original_db_path = db_module.DB_PATH
|
||||||
|
db_module.DB_PATH = test_db_path
|
||||||
|
db_module.DB_DIR = tmp_path
|
||||||
|
|
||||||
|
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||||
|
db_module._local.connection.close()
|
||||||
|
db_module._local.connection = None
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||||
|
db_module._local.connection.close()
|
||||||
|
db_module._local.connection = None
|
||||||
|
db_module.DB_PATH = original_db_path
|
||||||
|
|
||||||
|
def test_store_push_payload(self):
|
||||||
|
"""store_push_payload should insert payload."""
|
||||||
|
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||||
|
|
||||||
|
payload = {'aircraft': [{'icao': 'ABC123', 'altitude': 35000}]}
|
||||||
|
payload_id = store_push_payload(agent_id, 'adsb', payload, 'rtlsdr0')
|
||||||
|
|
||||||
|
assert payload_id > 0
|
||||||
|
|
||||||
|
def test_get_recent_payloads(self):
|
||||||
|
"""get_recent_payloads should return stored payloads."""
|
||||||
|
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||||
|
|
||||||
|
store_push_payload(agent_id, 'adsb', {'aircraft': [{'icao': 'A'}]})
|
||||||
|
store_push_payload(agent_id, 'adsb', {'aircraft': [{'icao': 'B'}]})
|
||||||
|
store_push_payload(agent_id, 'wifi', {'networks': []})
|
||||||
|
|
||||||
|
# Get all
|
||||||
|
payloads = get_recent_payloads(agent_id=agent_id)
|
||||||
|
assert len(payloads) == 3
|
||||||
|
|
||||||
|
# Filter by scan_type
|
||||||
|
adsb_payloads = get_recent_payloads(agent_id=agent_id, scan_type='adsb')
|
||||||
|
assert len(adsb_payloads) == 2
|
||||||
|
|
||||||
|
def test_get_recent_payloads_includes_agent_name(self):
|
||||||
|
"""Payloads should include agent name."""
|
||||||
|
agent_id = create_agent(name='my-sensor', base_url='http://localhost:8020')
|
||||||
|
store_push_payload(agent_id, 'sensor', {'temperature': 22.5})
|
||||||
|
|
||||||
|
payloads = get_recent_payloads(agent_id=agent_id)
|
||||||
|
|
||||||
|
assert len(payloads) > 0
|
||||||
|
assert payloads[0]['agent_name'] == 'my-sensor'
|
||||||
|
|
||||||
|
def test_get_recent_payloads_limit(self):
|
||||||
|
"""get_recent_payloads should respect limit."""
|
||||||
|
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
store_push_payload(agent_id, 'sensor', {'temp': i})
|
||||||
|
|
||||||
|
payloads = get_recent_payloads(agent_id=agent_id, limit=5)
|
||||||
|
assert len(payloads) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Integration Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAgentClientIntegration:
|
||||||
|
"""Integration tests using mock agent server."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_agent(self):
|
||||||
|
"""Start mock agent server for testing."""
|
||||||
|
from tests.mock_agent import app as mock_app
|
||||||
|
import threading
|
||||||
|
|
||||||
|
# Run mock agent in background
|
||||||
|
mock_app.config['TESTING'] = True
|
||||||
|
# Using Flask's test client instead of actual server
|
||||||
|
return mock_app.test_client()
|
||||||
|
|
||||||
|
def test_mock_agent_capabilities(self, mock_agent):
|
||||||
|
"""Mock agent should return capabilities."""
|
||||||
|
response = mock_agent.get('/capabilities')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'modes' in data
|
||||||
|
assert data['modes']['adsb'] is True
|
||||||
|
|
||||||
|
def test_mock_agent_start_stop_mode(self, mock_agent):
|
||||||
|
"""Mock agent should start/stop modes."""
|
||||||
|
# Start
|
||||||
|
response = mock_agent.post('/adsb/start', json={})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'started'
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
response = mock_agent.get('/status')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'adsb' in data['running_modes']
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
response = mock_agent.post('/adsb/stop', json={})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_mock_agent_data(self, mock_agent):
|
||||||
|
"""Mock agent should return data when mode is running."""
|
||||||
|
# Start mode first
|
||||||
|
mock_agent.post('/adsb/start', json={})
|
||||||
|
|
||||||
|
response = mock_agent.get('/adsb/data')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'data' in data
|
||||||
|
# Data should be a list of aircraft
|
||||||
|
assert isinstance(data['data'], list)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
mock_agent.post('/adsb/stop', json={})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GPS Manager Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGPSManager:
|
||||||
|
"""Tests for GPS integration in agent."""
|
||||||
|
|
||||||
|
def test_gps_manager_init(self):
|
||||||
|
"""GPSManager should initialize without error."""
|
||||||
|
from intercept_agent import GPSManager
|
||||||
|
gps = GPSManager()
|
||||||
|
assert gps.position is None
|
||||||
|
assert gps._running is False
|
||||||
|
|
||||||
|
def test_gps_manager_position_format(self):
|
||||||
|
"""GPSManager position should have correct format when set."""
|
||||||
|
from intercept_agent import GPSManager
|
||||||
|
|
||||||
|
gps = GPSManager()
|
||||||
|
|
||||||
|
# Simulate a position update
|
||||||
|
class MockPosition:
|
||||||
|
latitude = 40.7128
|
||||||
|
longitude = -74.0060
|
||||||
|
altitude = 10.5
|
||||||
|
speed = 0.0
|
||||||
|
heading = 180.0
|
||||||
|
fix_quality = 2
|
||||||
|
|
||||||
|
gps._position = MockPosition()
|
||||||
|
pos = gps.position
|
||||||
|
|
||||||
|
assert pos is not None
|
||||||
|
assert pos['lat'] == 40.7128
|
||||||
|
assert pos['lon'] == -74.0060
|
||||||
|
assert pos['altitude'] == 10.5
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Integration tests for Intercept Agent with real tools.
|
||||||
|
|
||||||
|
These tests verify:
|
||||||
|
- Tool detection and availability
|
||||||
|
- Output parsing with sample/recorded data
|
||||||
|
- Live tool execution (optional, requires hardware)
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
pytest tests/test_agent_integration.py -v
|
||||||
|
|
||||||
|
Run live tests (requires RTL-SDR hardware):
|
||||||
|
pytest tests/test_agent_integration.py -v -m live
|
||||||
|
|
||||||
|
Skip live tests:
|
||||||
|
pytest tests/test_agent_integration.py -v -m "not live"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Sample Data for Parsing Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Sample rtl_433 JSON outputs
|
||||||
|
RTL_433_SAMPLES = [
|
||||||
|
'{"time":"2024-01-15 10:30:00","model":"Acurite-Tower","id":12345,"channel":"A","battery_ok":1,"temperature_C":22.5,"humidity":45}',
|
||||||
|
'{"time":"2024-01-15 10:30:05","model":"Oregon-THGR122N","id":100,"channel":1,"battery_ok":1,"temperature_C":18.3,"humidity":62}',
|
||||||
|
'{"time":"2024-01-15 10:30:10","model":"LaCrosse-TX141W","id":55,"channel":2,"temperature_C":-5.2,"humidity":78}',
|
||||||
|
'{"time":"2024-01-15 10:30:15","model":"Ambient-F007TH","id":200,"channel":3,"temperature_C":25.0,"humidity":50,"battery_ok":1}',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sample SBS (BaseStation) format lines from dump1090
|
||||||
|
SBS_SAMPLES = [
|
||||||
|
'MSG,1,1,1,A1B2C3,1,2024/01/15,10:30:00.000,2024/01/15,10:30:00.000,UAL123,,,,,,,,,,0',
|
||||||
|
'MSG,3,1,1,A1B2C3,1,2024/01/15,10:30:01.000,2024/01/15,10:30:01.000,,35000,,,40.7128,-74.0060,,,0,0,0,0',
|
||||||
|
'MSG,4,1,1,A1B2C3,1,2024/01/15,10:30:02.000,2024/01/15,10:30:02.000,,,450,180,,,1500,,,,,',
|
||||||
|
'MSG,5,1,1,A1B2C3,1,2024/01/15,10:30:03.000,2024/01/15,10:30:03.000,UAL123,35000,,,,,,,,,',
|
||||||
|
'MSG,6,1,1,A1B2C3,1,2024/01/15,10:30:04.000,2024/01/15,10:30:04.000,,,,,,,,,,1200',
|
||||||
|
# Second aircraft
|
||||||
|
'MSG,1,1,1,D4E5F6,1,2024/01/15,10:30:05.000,2024/01/15,10:30:05.000,DAL456,,,,,,,,,,0',
|
||||||
|
'MSG,3,1,1,D4E5F6,1,2024/01/15,10:30:06.000,2024/01/15,10:30:06.000,,28000,,,40.8000,-73.9500,,,0,0,0,0',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sample airodump-ng CSV output (matches real airodump format - no blank line between header and data)
|
||||||
|
AIRODUMP_CSV_SAMPLE = """BSSID, First time seen, Last time seen, channel, Speed, Privacy, Cipher, Authentication, Power, # beacons, # IV, LAN IP, ID-length, ESSID, Key
|
||||||
|
00:11:22:33:44:55, 2024-01-15 10:00:00, 2024-01-15 10:30:00, 6, 54, WPA2, CCMP, PSK, -55, 100, 0, 0. 0. 0. 0, 8, HomeWiFi,
|
||||||
|
AA:BB:CC:DD:EE:FF, 2024-01-15 10:05:00, 2024-01-15 10:30:00, 11, 130, WPA2, CCMP, PSK, -70, 200, 0, 0. 0. 0. 0, 12, CoffeeShop,
|
||||||
|
11:22:33:44:55:66, 2024-01-15 10:10:00, 2024-01-15 10:30:00, 36, 867, WPA3, CCMP, SAE, -45, 150, 0, 0. 0. 0. 0, 7, Office5G,
|
||||||
|
|
||||||
|
Station MAC, First time seen, Last time seen, Power, # packets, BSSID, Probed ESSIDs
|
||||||
|
CA:FE:BA:BE:00:01, 2024-01-15 10:15:00, 2024-01-15 10:30:00, -60, 50, 00:11:22:33:44:55, HomeWiFi
|
||||||
|
DE:AD:BE:EF:00:02, 2024-01-15 10:20:00, 2024-01-15 10:30:00, -75, 25, AA:BB:CC:DD:EE:FF, CoffeeShop
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent():
|
||||||
|
"""Create a ModeManager instance for testing."""
|
||||||
|
from intercept_agent import ModeManager
|
||||||
|
return ModeManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_csv_file():
|
||||||
|
"""Create a temp airodump CSV file."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', suffix='-01.csv', delete=False) as f:
|
||||||
|
f.write(AIRODUMP_CSV_SAMPLE)
|
||||||
|
path = f.name
|
||||||
|
yield path[:-7] # Return base path without -01.csv suffix
|
||||||
|
# Cleanup
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tool Detection Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestToolDetection:
|
||||||
|
"""Tests for tool availability detection."""
|
||||||
|
|
||||||
|
def test_rtl_433_available(self):
|
||||||
|
"""rtl_433 should be installed."""
|
||||||
|
assert shutil.which('rtl_433') is not None
|
||||||
|
|
||||||
|
def test_dump1090_available(self):
|
||||||
|
"""dump1090 should be installed."""
|
||||||
|
assert shutil.which('dump1090') is not None or \
|
||||||
|
shutil.which('dump1090-fa') is not None or \
|
||||||
|
shutil.which('readsb') is not None
|
||||||
|
|
||||||
|
def test_airodump_available(self):
|
||||||
|
"""airodump-ng should be installed."""
|
||||||
|
assert shutil.which('airodump-ng') is not None
|
||||||
|
|
||||||
|
def test_multimon_available(self):
|
||||||
|
"""multimon-ng should be installed."""
|
||||||
|
assert shutil.which('multimon-ng') is not None
|
||||||
|
|
||||||
|
def test_acarsdec_available(self):
|
||||||
|
"""acarsdec should be installed."""
|
||||||
|
assert shutil.which('acarsdec') is not None
|
||||||
|
|
||||||
|
def test_agent_detects_tools(self, agent):
|
||||||
|
"""Agent should detect available tools."""
|
||||||
|
caps = agent.detect_capabilities()
|
||||||
|
|
||||||
|
# These should all be True given the tools are installed
|
||||||
|
assert caps['modes']['sensor'] is True
|
||||||
|
assert caps['modes']['adsb'] is True
|
||||||
|
# wifi requires airmon-ng too
|
||||||
|
# bluetooth requires bluetoothctl
|
||||||
|
|
||||||
|
|
||||||
|
class TestRTLSDRDetection:
|
||||||
|
"""Tests for RTL-SDR hardware detection."""
|
||||||
|
|
||||||
|
def test_rtl_test_runs(self):
|
||||||
|
"""rtl_test should run (even if no device)."""
|
||||||
|
result = subprocess.run(
|
||||||
|
['rtl_test', '-t'],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
# Will return 0 if device found, non-zero if not
|
||||||
|
# We just verify it runs without crashing
|
||||||
|
assert result.returncode in [0, 1, 255]
|
||||||
|
|
||||||
|
def test_agent_detects_sdr_devices(self, agent):
|
||||||
|
"""Agent should detect SDR devices."""
|
||||||
|
caps = agent.detect_capabilities()
|
||||||
|
|
||||||
|
# If RTL-SDR is connected, devices list should be non-empty
|
||||||
|
# This is hardware-dependent, so we just verify the key exists
|
||||||
|
assert 'devices' in caps
|
||||||
|
|
||||||
|
@pytest.mark.live
|
||||||
|
def test_rtl_sdr_present(self):
|
||||||
|
"""Verify RTL-SDR device is present (for live tests)."""
|
||||||
|
result = subprocess.run(
|
||||||
|
['rtl_test', '-t'],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if b'Found 0 device' in result.stdout or b'No supported devices found' in result.stderr:
|
||||||
|
pytest.skip("No RTL-SDR device connected")
|
||||||
|
assert b'Found' in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Parsing Tests (No Hardware Required)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestRTL433Parsing:
|
||||||
|
"""Tests for rtl_433 JSON output parsing."""
|
||||||
|
|
||||||
|
def test_parse_acurite_sensor(self):
|
||||||
|
"""Parse Acurite temperature sensor data."""
|
||||||
|
data = json.loads(RTL_433_SAMPLES[0])
|
||||||
|
|
||||||
|
assert data['model'] == 'Acurite-Tower'
|
||||||
|
assert data['id'] == 12345
|
||||||
|
assert data['temperature_C'] == 22.5
|
||||||
|
assert data['humidity'] == 45
|
||||||
|
assert data['battery_ok'] == 1
|
||||||
|
|
||||||
|
def test_parse_oregon_sensor(self):
|
||||||
|
"""Parse Oregon Scientific sensor data."""
|
||||||
|
data = json.loads(RTL_433_SAMPLES[1])
|
||||||
|
|
||||||
|
assert data['model'] == 'Oregon-THGR122N'
|
||||||
|
assert data['temperature_C'] == 18.3
|
||||||
|
|
||||||
|
def test_parse_negative_temperature(self):
|
||||||
|
"""Parse sensor with negative temperature."""
|
||||||
|
data = json.loads(RTL_433_SAMPLES[2])
|
||||||
|
|
||||||
|
assert data['model'] == 'LaCrosse-TX141W'
|
||||||
|
assert data['temperature_C'] == -5.2
|
||||||
|
|
||||||
|
def test_agent_sensor_data_format(self, agent):
|
||||||
|
"""Agent should format sensor data correctly for controller."""
|
||||||
|
# Simulate processing
|
||||||
|
sample = json.loads(RTL_433_SAMPLES[0])
|
||||||
|
sample['type'] = 'sensor'
|
||||||
|
sample['received_at'] = '2024-01-15T10:30:00Z'
|
||||||
|
|
||||||
|
# Verify required fields for controller
|
||||||
|
assert 'model' in sample
|
||||||
|
assert 'temperature_C' in sample or 'temperature_F' in sample
|
||||||
|
assert 'received_at' in sample
|
||||||
|
|
||||||
|
|
||||||
|
class TestSBSParsing:
|
||||||
|
"""Tests for SBS (BaseStation) format parsing from dump1090."""
|
||||||
|
|
||||||
|
def test_parse_msg1_callsign(self, agent):
|
||||||
|
"""MSG,1 should extract callsign."""
|
||||||
|
line = SBS_SAMPLES[0]
|
||||||
|
agent._parse_sbs_line(line)
|
||||||
|
|
||||||
|
aircraft = agent.adsb_aircraft.get('A1B2C3')
|
||||||
|
assert aircraft is not None
|
||||||
|
assert aircraft['callsign'] == 'UAL123'
|
||||||
|
|
||||||
|
def test_parse_msg3_position(self, agent):
|
||||||
|
"""MSG,3 should extract altitude and position."""
|
||||||
|
agent._parse_sbs_line(SBS_SAMPLES[0]) # First need MSG,1 for ICAO
|
||||||
|
agent._parse_sbs_line(SBS_SAMPLES[1])
|
||||||
|
|
||||||
|
aircraft = agent.adsb_aircraft.get('A1B2C3')
|
||||||
|
assert aircraft is not None
|
||||||
|
assert aircraft['altitude'] == 35000
|
||||||
|
assert abs(aircraft['lat'] - 40.7128) < 0.0001
|
||||||
|
assert abs(aircraft['lon'] - (-74.0060)) < 0.0001
|
||||||
|
|
||||||
|
def test_parse_msg4_velocity(self, agent):
|
||||||
|
"""MSG,4 should extract speed and heading."""
|
||||||
|
agent._parse_sbs_line(SBS_SAMPLES[0])
|
||||||
|
agent._parse_sbs_line(SBS_SAMPLES[2])
|
||||||
|
|
||||||
|
aircraft = agent.adsb_aircraft.get('A1B2C3')
|
||||||
|
assert aircraft is not None
|
||||||
|
assert aircraft['speed'] == 450
|
||||||
|
assert aircraft['heading'] == 180
|
||||||
|
assert aircraft['vertical_rate'] == 1500
|
||||||
|
|
||||||
|
def test_parse_msg6_squawk(self, agent):
|
||||||
|
"""MSG,6 should extract squawk code."""
|
||||||
|
agent._parse_sbs_line(SBS_SAMPLES[0])
|
||||||
|
agent._parse_sbs_line(SBS_SAMPLES[4])
|
||||||
|
|
||||||
|
aircraft = agent.adsb_aircraft.get('A1B2C3')
|
||||||
|
assert aircraft is not None
|
||||||
|
# Squawk may not be present if MSG,6 format doesn't have enough fields
|
||||||
|
# The sample line may need adjustment - check if squawk was parsed
|
||||||
|
if 'squawk' in aircraft:
|
||||||
|
assert aircraft['squawk'] == '1200'
|
||||||
|
|
||||||
|
def test_parse_multiple_aircraft(self, agent):
|
||||||
|
"""Should track multiple aircraft simultaneously."""
|
||||||
|
for line in SBS_SAMPLES:
|
||||||
|
agent._parse_sbs_line(line)
|
||||||
|
|
||||||
|
assert 'A1B2C3' in agent.adsb_aircraft
|
||||||
|
assert 'D4E5F6' in agent.adsb_aircraft
|
||||||
|
assert agent.adsb_aircraft['D4E5F6']['callsign'] == 'DAL456'
|
||||||
|
|
||||||
|
def test_parse_malformed_sbs(self, agent):
|
||||||
|
"""Should handle malformed SBS lines gracefully."""
|
||||||
|
# Too few fields
|
||||||
|
agent._parse_sbs_line('MSG,1,1')
|
||||||
|
# Not MSG type
|
||||||
|
agent._parse_sbs_line('SEL,1,1,1,ABC123,1')
|
||||||
|
# Empty line
|
||||||
|
agent._parse_sbs_line('')
|
||||||
|
# Garbage
|
||||||
|
agent._parse_sbs_line('not,valid,sbs,data')
|
||||||
|
|
||||||
|
# Should not crash, aircraft dict should be empty
|
||||||
|
assert len(agent.adsb_aircraft) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestAirodumpParsing:
|
||||||
|
"""Tests for airodump-ng CSV parsing using Intercept's parser."""
|
||||||
|
|
||||||
|
def test_intercept_parser_available(self):
|
||||||
|
"""Intercept's airodump parser should be importable."""
|
||||||
|
from utils.wifi.parsers.airodump import parse_airodump_csv
|
||||||
|
assert callable(parse_airodump_csv)
|
||||||
|
|
||||||
|
def test_parse_csv_networks_with_intercept_parser(self, temp_csv_file):
|
||||||
|
"""Intercept parser should parse network section of CSV."""
|
||||||
|
from utils.wifi.parsers.airodump import parse_airodump_csv
|
||||||
|
|
||||||
|
networks, clients = parse_airodump_csv(temp_csv_file + '-01.csv')
|
||||||
|
|
||||||
|
assert len(networks) >= 3
|
||||||
|
|
||||||
|
# Find HomeWiFi network by BSSID
|
||||||
|
home_wifi = next((n for n in networks if n.bssid == '00:11:22:33:44:55'), None)
|
||||||
|
assert home_wifi is not None
|
||||||
|
assert home_wifi.essid == 'HomeWiFi'
|
||||||
|
assert home_wifi.channel == 6
|
||||||
|
assert home_wifi.rssi == -55
|
||||||
|
assert 'WPA2' in home_wifi.security # Could be 'WPA2' or 'WPA/WPA2'
|
||||||
|
|
||||||
|
def test_parse_csv_clients_with_intercept_parser(self, temp_csv_file):
|
||||||
|
"""Intercept parser should parse client section of CSV."""
|
||||||
|
from utils.wifi.parsers.airodump import parse_airodump_csv
|
||||||
|
|
||||||
|
networks, clients = parse_airodump_csv(temp_csv_file + '-01.csv')
|
||||||
|
|
||||||
|
assert len(clients) >= 2
|
||||||
|
# Client should have MAC and associated BSSID
|
||||||
|
assert any(c.get('mac') == 'CA:FE:BA:BE:00:01' for c in clients)
|
||||||
|
|
||||||
|
def test_agent_uses_intercept_parser(self, agent, temp_csv_file):
|
||||||
|
"""Agent should use Intercept's parser when available."""
|
||||||
|
networks, clients = agent._parse_airodump_csv(temp_csv_file + '-01.csv', None)
|
||||||
|
|
||||||
|
# Should return dict format
|
||||||
|
assert isinstance(networks, dict)
|
||||||
|
assert len(networks) >= 3
|
||||||
|
|
||||||
|
# Check a network entry
|
||||||
|
home_wifi = networks.get('00:11:22:33:44:55')
|
||||||
|
assert home_wifi is not None
|
||||||
|
assert home_wifi['essid'] == 'HomeWiFi'
|
||||||
|
assert home_wifi['channel'] == 6
|
||||||
|
|
||||||
|
def test_parse_csv_clients(self, agent, temp_csv_file):
|
||||||
|
"""Agent should parse clients correctly."""
|
||||||
|
networks, clients = agent._parse_airodump_csv(temp_csv_file + '-01.csv', None)
|
||||||
|
|
||||||
|
assert len(clients) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Live Tool Tests (Require Hardware)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.live
|
||||||
|
class TestLiveRTL433:
|
||||||
|
"""Live tests with rtl_433 (requires RTL-SDR)."""
|
||||||
|
|
||||||
|
def test_rtl_433_runs(self):
|
||||||
|
"""rtl_433 should start and produce output."""
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
['rtl_433', '-F', 'json', '-T', '3'], # Run for 3 seconds
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = proc.communicate(timeout=10)
|
||||||
|
# rtl_433 may or may not receive data in 3 seconds
|
||||||
|
# We just verify it starts without error
|
||||||
|
assert proc.returncode in [0, 1] # 1 = no data received, OK
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
pytest.fail("rtl_433 did not complete in time")
|
||||||
|
|
||||||
|
def test_rtl_433_json_output(self):
|
||||||
|
"""rtl_433 JSON output should be parseable."""
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
['rtl_433', '-F', 'json', '-T', '5'],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, _ = proc.communicate(timeout=10)
|
||||||
|
# If we got any output, verify it's valid JSON
|
||||||
|
for line in stdout.decode('utf-8', errors='ignore').split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
assert 'model' in data or 'time' in data
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass # May be startup messages
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.live
|
||||||
|
class TestLiveDump1090:
|
||||||
|
"""Live tests with dump1090 (requires RTL-SDR)."""
|
||||||
|
|
||||||
|
def test_dump1090_starts(self):
|
||||||
|
"""dump1090 should start successfully."""
|
||||||
|
dump1090_path = shutil.which('dump1090') or shutil.which('dump1090-fa')
|
||||||
|
if not dump1090_path:
|
||||||
|
pytest.skip("dump1090 not installed")
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[dump1090_path, '--net', '--quiet'],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
time.sleep(2)
|
||||||
|
if proc.poll() is not None:
|
||||||
|
stderr = proc.stderr.read().decode()
|
||||||
|
if 'No supported RTLSDR devices found' in stderr:
|
||||||
|
pytest.skip("No RTL-SDR for ADS-B")
|
||||||
|
pytest.fail(f"dump1090 exited: {stderr}")
|
||||||
|
|
||||||
|
# Verify SBS port is open
|
||||||
|
import socket
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
result = sock.connect_ex(('localhost', 30003))
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
assert result == 0, "SBS port 30003 not open"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.live
|
||||||
|
class TestLiveAgentModes:
|
||||||
|
"""Live tests running agent modes (requires hardware)."""
|
||||||
|
|
||||||
|
def test_agent_sensor_mode(self, agent):
|
||||||
|
"""Agent should start and stop sensor mode."""
|
||||||
|
result = agent.start_mode('sensor', {})
|
||||||
|
|
||||||
|
if result.get('status') == 'error':
|
||||||
|
if 'not found' in result.get('message', ''):
|
||||||
|
pytest.skip("rtl_433 not found")
|
||||||
|
if 'device' in result.get('message', '').lower():
|
||||||
|
pytest.skip("No RTL-SDR device")
|
||||||
|
|
||||||
|
assert result['status'] == 'started'
|
||||||
|
assert 'sensor' in agent.running_modes
|
||||||
|
|
||||||
|
# Let it run briefly
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
status = agent.get_mode_status('sensor')
|
||||||
|
assert status['running'] is True
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
stop_result = agent.stop_mode('sensor')
|
||||||
|
assert stop_result['status'] == 'stopped'
|
||||||
|
assert 'sensor' not in agent.running_modes
|
||||||
|
|
||||||
|
def test_agent_adsb_mode(self, agent):
|
||||||
|
"""Agent should start and stop ADS-B mode."""
|
||||||
|
result = agent.start_mode('adsb', {})
|
||||||
|
|
||||||
|
if result.get('status') == 'error':
|
||||||
|
if 'not found' in result.get('message', ''):
|
||||||
|
pytest.skip("dump1090 not found")
|
||||||
|
if 'device' in result.get('message', '').lower():
|
||||||
|
pytest.skip("No RTL-SDR device")
|
||||||
|
|
||||||
|
assert result['status'] == 'started'
|
||||||
|
|
||||||
|
# Let it run briefly
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# Get data (may be empty if no aircraft)
|
||||||
|
data = agent.get_mode_data('adsb')
|
||||||
|
assert 'data' in data
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
agent.stop_mode('adsb')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Controller Integration Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAgentControllerFormat:
|
||||||
|
"""Tests that agent output matches controller expectations."""
|
||||||
|
|
||||||
|
def test_sensor_data_format(self, agent):
|
||||||
|
"""Sensor data should have required fields for controller."""
|
||||||
|
# Simulate parsed data
|
||||||
|
sample = {
|
||||||
|
'model': 'Acurite-Tower',
|
||||||
|
'id': 12345,
|
||||||
|
'temperature_C': 22.5,
|
||||||
|
'humidity': 45,
|
||||||
|
'type': 'sensor',
|
||||||
|
'received_at': '2024-01-15T10:30:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should be serializable
|
||||||
|
json_str = json.dumps(sample)
|
||||||
|
restored = json.loads(json_str)
|
||||||
|
assert restored['model'] == 'Acurite-Tower'
|
||||||
|
|
||||||
|
def test_adsb_data_format(self, agent):
|
||||||
|
"""ADS-B data should have required fields for controller."""
|
||||||
|
# Simulate parsed aircraft
|
||||||
|
agent._parse_sbs_line(SBS_SAMPLES[0])
|
||||||
|
agent._parse_sbs_line(SBS_SAMPLES[1])
|
||||||
|
agent._parse_sbs_line(SBS_SAMPLES[2])
|
||||||
|
|
||||||
|
data = agent.get_mode_data('adsb')
|
||||||
|
|
||||||
|
# Should be list format
|
||||||
|
assert isinstance(data['data'], list)
|
||||||
|
|
||||||
|
if data['data']:
|
||||||
|
aircraft = data['data'][0]
|
||||||
|
assert 'icao' in aircraft
|
||||||
|
assert 'last_seen' in aircraft
|
||||||
|
|
||||||
|
def test_push_payload_format(self, agent):
|
||||||
|
"""Push payload should match controller ingest format."""
|
||||||
|
# Simulate what agent sends to controller
|
||||||
|
payload = {
|
||||||
|
'agent_name': 'test-sensor',
|
||||||
|
'scan_type': 'adsb',
|
||||||
|
'interface': 'rtlsdr0',
|
||||||
|
'payload': {
|
||||||
|
'aircraft': [
|
||||||
|
{'icao': 'A1B2C3', 'callsign': 'UAL123', 'altitude': 35000}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'received_at': '2024-01-15T10:30:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify structure
|
||||||
|
assert 'agent_name' in payload
|
||||||
|
assert 'scan_type' in payload
|
||||||
|
assert 'payload' in payload
|
||||||
|
|
||||||
|
# Should be JSON serializable
|
||||||
|
json_str = json.dumps(payload)
|
||||||
|
assert len(json_str) > 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GPS Integration Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGPSIntegration:
|
||||||
|
"""Tests for GPS data in agent output."""
|
||||||
|
|
||||||
|
def test_data_includes_gps_field(self, agent):
|
||||||
|
"""Data should include GPS position if available."""
|
||||||
|
data = agent.get_mode_data('sensor')
|
||||||
|
|
||||||
|
# agent_gps field should exist (may be None if no GPS)
|
||||||
|
assert 'agent_gps' in data or data.get('agent_gps') is None
|
||||||
|
|
||||||
|
def test_gps_position_format(self):
|
||||||
|
"""GPS position should have lat/lon fields."""
|
||||||
|
from intercept_agent import GPSManager
|
||||||
|
|
||||||
|
gps = GPSManager()
|
||||||
|
|
||||||
|
# Simulate position
|
||||||
|
class MockPosition:
|
||||||
|
latitude = 40.7128
|
||||||
|
longitude = -74.0060
|
||||||
|
altitude = 10.0
|
||||||
|
speed = 0.0
|
||||||
|
heading = 0.0
|
||||||
|
fix_quality = 2
|
||||||
|
|
||||||
|
gps._position = MockPosition()
|
||||||
|
pos = gps.position
|
||||||
|
|
||||||
|
assert pos is not None
|
||||||
|
assert 'lat' in pos
|
||||||
|
assert 'lon' in pos
|
||||||
|
assert pos['lat'] == 40.7128
|
||||||
|
assert pos['lon'] == -74.0060
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Run Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
pytest.main([__file__, '-v', '-m', 'not live'])
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"""
|
||||||
|
Comprehensive tests for Intercept Agent mode operations.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- All 13 mode start/stop lifecycles
|
||||||
|
- SDR device conflict detection
|
||||||
|
- Process verification (subprocess failure handling)
|
||||||
|
- Data snapshot operations
|
||||||
|
- Multi-mode scenarios
|
||||||
|
- Error handling and edge cases
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
import threading
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mode_manager():
|
||||||
|
"""Create a fresh ModeManager instance for testing."""
|
||||||
|
from intercept_agent import ModeManager
|
||||||
|
manager = ModeManager()
|
||||||
|
yield manager
|
||||||
|
# Cleanup: stop all modes
|
||||||
|
for mode in list(manager.running_modes.keys()):
|
||||||
|
try:
|
||||||
|
manager.stop_mode(mode)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_subprocess():
|
||||||
|
"""Mock subprocess.Popen for controlled testing."""
|
||||||
|
with patch('subprocess.Popen') as mock_popen:
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.poll.return_value = None # Process is running
|
||||||
|
mock_proc.stdout = MagicMock()
|
||||||
|
mock_proc.stderr = MagicMock()
|
||||||
|
mock_proc.stderr.read.return_value = b''
|
||||||
|
mock_proc.stdin = MagicMock()
|
||||||
|
mock_proc.pid = 12345
|
||||||
|
mock_proc.wait.return_value = 0
|
||||||
|
mock_popen.return_value = mock_proc
|
||||||
|
yield mock_popen, mock_proc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_tools():
|
||||||
|
"""Mock tool availability checks."""
|
||||||
|
tools = {
|
||||||
|
'rtl_433': '/usr/bin/rtl_433',
|
||||||
|
'rtl_fm': '/usr/bin/rtl_fm',
|
||||||
|
'dump1090': '/usr/bin/dump1090',
|
||||||
|
'multimon-ng': '/usr/bin/multimon-ng',
|
||||||
|
'airodump-ng': '/usr/sbin/airodump-ng',
|
||||||
|
'acarsdec': '/usr/bin/acarsdec',
|
||||||
|
'AIS-catcher': '/usr/bin/AIS-catcher',
|
||||||
|
'direwolf': '/usr/bin/direwolf',
|
||||||
|
'rtlamr': '/usr/bin/rtlamr',
|
||||||
|
'rtl_tcp': '/usr/bin/rtl_tcp',
|
||||||
|
'bluetoothctl': '/usr/bin/bluetoothctl',
|
||||||
|
}
|
||||||
|
with patch('shutil.which', side_effect=lambda x: tools.get(x)):
|
||||||
|
yield tools
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SDR Mode List
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SDR_MODES = ['sensor', 'adsb', 'pager', 'ais', 'acars', 'aprs', 'rtlamr', 'dsc', 'listening_post']
|
||||||
|
NON_SDR_MODES = ['wifi', 'bluetooth', 'tscm', 'satellite']
|
||||||
|
ALL_MODES = SDR_MODES + NON_SDR_MODES
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Mode Lifecycle Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestModeLifecycle:
|
||||||
|
"""Test start/stop lifecycle for all modes."""
|
||||||
|
|
||||||
|
def test_sensor_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Sensor mode should start and stop cleanly."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
# Start
|
||||||
|
result = mode_manager.start_mode('sensor', {'frequency': '433.92', 'device': '0'})
|
||||||
|
assert result['status'] == 'started'
|
||||||
|
assert 'sensor' in mode_manager.running_modes
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
result = mode_manager.stop_mode('sensor')
|
||||||
|
assert result['status'] == 'stopped'
|
||||||
|
assert 'sensor' not in mode_manager.running_modes
|
||||||
|
|
||||||
|
def test_adsb_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""ADS-B mode should start and stop cleanly."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
# Mock socket for SBS connection check
|
||||||
|
with patch('socket.socket') as mock_socket:
|
||||||
|
mock_sock = MagicMock()
|
||||||
|
mock_sock.connect_ex.return_value = 1 # Port not in use
|
||||||
|
mock_socket.return_value = mock_sock
|
||||||
|
|
||||||
|
result = mode_manager.start_mode('adsb', {'device': '0', 'gain': '40'})
|
||||||
|
# May fail due to SBS port check, but shouldn't crash
|
||||||
|
assert result['status'] in ['started', 'error']
|
||||||
|
|
||||||
|
def test_pager_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Pager mode should start and stop cleanly."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
result = mode_manager.start_mode('pager', {
|
||||||
|
'frequency': '929.6125',
|
||||||
|
'protocols': ['POCSAG512', 'POCSAG1200']
|
||||||
|
})
|
||||||
|
assert result['status'] == 'started'
|
||||||
|
assert 'pager' in mode_manager.running_modes
|
||||||
|
|
||||||
|
result = mode_manager.stop_mode('pager')
|
||||||
|
assert result['status'] == 'stopped'
|
||||||
|
|
||||||
|
def test_wifi_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""WiFi mode should start and stop cleanly."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
# Mock glob for CSV file detection
|
||||||
|
with patch('glob.glob', return_value=[]):
|
||||||
|
with patch('tempfile.mkdtemp', return_value='/tmp/test'):
|
||||||
|
result = mode_manager.start_mode('wifi', {
|
||||||
|
'interface': 'wlan0',
|
||||||
|
'scan_type': 'quick'
|
||||||
|
})
|
||||||
|
# Quick scan returns data directly
|
||||||
|
assert result['status'] in ['started', 'error', 'success']
|
||||||
|
|
||||||
|
def test_bluetooth_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Bluetooth mode should start and stop cleanly."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
result = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
||||||
|
assert result['status'] == 'started'
|
||||||
|
assert 'bluetooth' in mode_manager.running_modes
|
||||||
|
|
||||||
|
# Give thread time to start
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
result = mode_manager.stop_mode('bluetooth')
|
||||||
|
assert result['status'] == 'stopped'
|
||||||
|
|
||||||
|
def test_satellite_mode_lifecycle(self, mode_manager):
|
||||||
|
"""Satellite mode should work without SDR."""
|
||||||
|
# Satellite mode is computational only
|
||||||
|
result = mode_manager.start_mode('satellite', {
|
||||||
|
'lat': 33.5,
|
||||||
|
'lon': -82.1,
|
||||||
|
'min_elevation': 10
|
||||||
|
})
|
||||||
|
assert result['status'] in ['started', 'error'] # May fail if skyfield not installed
|
||||||
|
|
||||||
|
def test_tscm_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""TSCM mode should start and stop cleanly."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
result = mode_manager.start_mode('tscm', {
|
||||||
|
'wifi': True,
|
||||||
|
'bluetooth': True,
|
||||||
|
'rf': False
|
||||||
|
})
|
||||||
|
assert result['status'] == 'started'
|
||||||
|
|
||||||
|
result = mode_manager.stop_mode('tscm')
|
||||||
|
assert result['status'] == 'stopped'
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SDR Conflict Detection Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestSDRConflictDetection:
|
||||||
|
"""Test SDR device conflict detection."""
|
||||||
|
|
||||||
|
def test_same_device_conflict(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Starting two SDR modes on same device should fail."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
# Start sensor on device 0
|
||||||
|
result1 = mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
assert result1['status'] == 'started'
|
||||||
|
|
||||||
|
# Try to start pager on device 0 - should fail
|
||||||
|
result2 = mode_manager.start_mode('pager', {'device': '0'})
|
||||||
|
assert result2['status'] == 'error'
|
||||||
|
assert 'in use' in result2['message'].lower()
|
||||||
|
|
||||||
|
def test_different_device_no_conflict(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Starting SDR modes on different devices should work."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
# Start sensor on device 0
|
||||||
|
result1 = mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
assert result1['status'] == 'started'
|
||||||
|
|
||||||
|
# Start pager on device 1 - should work
|
||||||
|
result2 = mode_manager.start_mode('pager', {'device': '1'})
|
||||||
|
assert result2['status'] == 'started'
|
||||||
|
|
||||||
|
assert len(mode_manager.running_modes) == 2
|
||||||
|
|
||||||
|
def test_non_sdr_modes_no_conflict(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Non-SDR modes should not conflict with SDR modes."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
# Start sensor (SDR)
|
||||||
|
result1 = mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
assert result1['status'] == 'started'
|
||||||
|
|
||||||
|
# Start bluetooth (non-SDR) - should work
|
||||||
|
result2 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
||||||
|
assert result2['status'] == 'started'
|
||||||
|
|
||||||
|
assert len(mode_manager.running_modes) == 2
|
||||||
|
|
||||||
|
def test_get_sdr_in_use(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""get_sdr_in_use should return correct mode."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
# No SDR in use initially
|
||||||
|
assert mode_manager.get_sdr_in_use(0) is None
|
||||||
|
|
||||||
|
# Start sensor
|
||||||
|
mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
|
||||||
|
# Device 0 now in use by sensor
|
||||||
|
assert mode_manager.get_sdr_in_use(0) == 'sensor'
|
||||||
|
assert mode_manager.get_sdr_in_use(1) is None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Process Verification Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestProcessVerification:
|
||||||
|
"""Test process startup verification."""
|
||||||
|
|
||||||
|
def test_immediate_process_exit_detected(self, mode_manager, mock_tools):
|
||||||
|
"""Process that exits immediately should return error."""
|
||||||
|
with patch('subprocess.Popen') as mock_popen:
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.poll.return_value = 1 # Process exited
|
||||||
|
mock_proc.stderr.read.return_value = b'device busy'
|
||||||
|
mock_popen.return_value = mock_proc
|
||||||
|
|
||||||
|
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
assert result['status'] == 'error'
|
||||||
|
assert 'sensor' not in mode_manager.running_modes
|
||||||
|
|
||||||
|
def test_running_process_accepted(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Process that stays running should be accepted."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
mock_proc.poll.return_value = None # Still running
|
||||||
|
|
||||||
|
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
assert result['status'] == 'started'
|
||||||
|
assert 'sensor' in mode_manager.running_modes
|
||||||
|
|
||||||
|
def test_error_message_from_stderr(self, mode_manager, mock_tools):
|
||||||
|
"""Error message should include stderr output."""
|
||||||
|
with patch('subprocess.Popen') as mock_popen:
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.poll.return_value = 1
|
||||||
|
mock_proc.stderr.read.return_value = b'usb_claim_interface error -6'
|
||||||
|
mock_popen.return_value = mock_proc
|
||||||
|
|
||||||
|
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
assert result['status'] == 'error'
|
||||||
|
assert 'usb_claim_interface' in result['message'] or 'failed' in result['message'].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Data Snapshot Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestDataSnapshots:
|
||||||
|
"""Test data snapshot operations."""
|
||||||
|
|
||||||
|
def test_get_mode_data_empty(self, mode_manager):
|
||||||
|
"""get_mode_data for non-running mode should return empty."""
|
||||||
|
result = mode_manager.get_mode_data('sensor')
|
||||||
|
assert result['mode'] == 'sensor'
|
||||||
|
# Mode not running - should have empty data or 'running' field
|
||||||
|
assert result.get('running') is False or result.get('data') == [] or 'status' in result
|
||||||
|
|
||||||
|
def test_get_mode_data_running(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""get_mode_data for running mode should return status."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
result = mode_manager.get_mode_data('sensor')
|
||||||
|
|
||||||
|
assert result['mode'] == 'sensor'
|
||||||
|
# Mode is running - should indicate running status
|
||||||
|
assert result.get('running') is True or 'data' in result or 'status' in result
|
||||||
|
|
||||||
|
def test_data_queue_limit(self, mode_manager):
|
||||||
|
"""Data queues should respect max size limits."""
|
||||||
|
import queue
|
||||||
|
|
||||||
|
# Manually test queue limit
|
||||||
|
test_queue = queue.Queue(maxsize=100)
|
||||||
|
for i in range(150):
|
||||||
|
if test_queue.full():
|
||||||
|
test_queue.get_nowait() # Remove old item
|
||||||
|
test_queue.put_nowait({'index': i})
|
||||||
|
|
||||||
|
assert test_queue.qsize() <= 100
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Mode Status Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestModeStatus:
|
||||||
|
"""Test mode status reporting."""
|
||||||
|
|
||||||
|
def test_status_includes_all_modes(self, mode_manager):
|
||||||
|
"""Status should include all running modes."""
|
||||||
|
status = mode_manager.get_status()
|
||||||
|
assert 'running_modes' in status
|
||||||
|
assert 'running_modes_detail' in status
|
||||||
|
assert isinstance(status['running_modes'], list)
|
||||||
|
|
||||||
|
def test_running_modes_detail_includes_device(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Running modes detail should include device info."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
status = mode_manager.get_status()
|
||||||
|
|
||||||
|
assert 'sensor' in status['running_modes_detail']
|
||||||
|
detail = status['running_modes_detail']['sensor']
|
||||||
|
assert 'device' in detail or 'params' in detail
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Error Handling Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""Test error handling scenarios."""
|
||||||
|
|
||||||
|
def test_missing_tool_returns_error(self, mode_manager):
|
||||||
|
"""Mode should fail gracefully if required tool is missing."""
|
||||||
|
with patch('shutil.which', return_value=None):
|
||||||
|
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
assert result['status'] == 'error'
|
||||||
|
# Error message may vary - check for common patterns
|
||||||
|
msg = result['message'].lower()
|
||||||
|
assert 'not found' in msg or 'not available' in msg or 'missing' in msg
|
||||||
|
|
||||||
|
def test_invalid_mode_returns_error(self, mode_manager):
|
||||||
|
"""Invalid mode name should return error."""
|
||||||
|
result = mode_manager.start_mode('invalid_mode', {})
|
||||||
|
assert result['status'] == 'error'
|
||||||
|
|
||||||
|
def test_double_start_returns_already_running(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Starting already-running mode should return appropriate status."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
|
||||||
|
assert result['status'] in ['already_running', 'error']
|
||||||
|
|
||||||
|
def test_stop_non_running_mode(self, mode_manager):
|
||||||
|
"""Stopping non-running mode should handle gracefully."""
|
||||||
|
result = mode_manager.stop_mode('sensor')
|
||||||
|
assert result['status'] in ['stopped', 'not_running']
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Cleanup Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCleanup:
|
||||||
|
"""Test mode cleanup on stop."""
|
||||||
|
|
||||||
|
def test_process_terminated_on_stop(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Processes should be terminated when mode is stopped."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
mode_manager.stop_mode('sensor')
|
||||||
|
|
||||||
|
# Verify terminate was called
|
||||||
|
mock_proc.terminate.assert_called()
|
||||||
|
|
||||||
|
def test_threads_stopped_on_stop(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Output threads should be stopped when mode is stopped."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
||||||
|
time.sleep(0.1) # Let thread start
|
||||||
|
|
||||||
|
mode_manager.stop_mode('bluetooth')
|
||||||
|
|
||||||
|
# Thread should no longer be in output_threads or should be stopped
|
||||||
|
assert 'bluetooth' not in mode_manager.output_threads or \
|
||||||
|
not mode_manager.output_threads['bluetooth'].is_alive()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Multi-Mode Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestMultiMode:
|
||||||
|
"""Test multiple modes running simultaneously."""
|
||||||
|
|
||||||
|
def test_multiple_non_sdr_modes(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Multiple non-SDR modes should run simultaneously."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
result1 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
||||||
|
result2 = mode_manager.start_mode('tscm', {'wifi': True, 'bluetooth': False})
|
||||||
|
|
||||||
|
assert result1['status'] == 'started'
|
||||||
|
assert result2['status'] == 'started'
|
||||||
|
assert len(mode_manager.running_modes) == 2
|
||||||
|
|
||||||
|
def test_stop_all_modes(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""All modes should stop cleanly."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
mode_manager.start_mode('bluetooth', {'adapter': 'hci0'})
|
||||||
|
|
||||||
|
# Stop all
|
||||||
|
for mode in list(mode_manager.running_modes.keys()):
|
||||||
|
mode_manager.stop_mode(mode)
|
||||||
|
|
||||||
|
assert len(mode_manager.running_modes) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GPS Integration Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGPSIntegration:
|
||||||
|
"""Test GPS coordinate integration."""
|
||||||
|
|
||||||
|
def test_status_includes_gps_flag(self, mode_manager):
|
||||||
|
"""Status should indicate GPS availability."""
|
||||||
|
status = mode_manager.get_status()
|
||||||
|
assert 'gps' in status
|
||||||
|
|
||||||
|
def test_mode_start_includes_gps_flag(self, mode_manager, mock_subprocess, mock_tools):
|
||||||
|
"""Mode start response should include GPS status."""
|
||||||
|
mock_popen, mock_proc = mock_subprocess
|
||||||
|
|
||||||
|
result = mode_manager.start_mode('sensor', {'device': '0'})
|
||||||
|
if result['status'] == 'started':
|
||||||
|
assert 'gps_enabled' in result
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Run Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
pytest.main([__file__, '-v'])
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
"""
|
||||||
|
Tests for Controller routes (multi-agent management).
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Agent CRUD operations via HTTP
|
||||||
|
- Proxy operations to agents
|
||||||
|
- Push data ingestion
|
||||||
|
- SSE streaming
|
||||||
|
- Location estimation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup_db(tmp_path):
|
||||||
|
"""Set up a temporary database."""
|
||||||
|
import utils.database as db_module
|
||||||
|
from utils.database import init_db
|
||||||
|
|
||||||
|
test_db_path = tmp_path / 'test.db'
|
||||||
|
original_db_path = db_module.DB_PATH
|
||||||
|
db_module.DB_PATH = test_db_path
|
||||||
|
db_module.DB_DIR = tmp_path
|
||||||
|
|
||||||
|
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||||
|
db_module._local.connection.close()
|
||||||
|
db_module._local.connection = None
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||||
|
db_module._local.connection.close()
|
||||||
|
db_module._local.connection = None
|
||||||
|
db_module.DB_PATH = original_db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(setup_db):
|
||||||
|
"""Create Flask app with controller blueprint."""
|
||||||
|
from flask import Flask
|
||||||
|
from routes.controller import controller_bp
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.register_blueprint(controller_bp)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
"""Create test client."""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_agent(setup_db):
|
||||||
|
"""Create a sample agent in database."""
|
||||||
|
from utils.database import create_agent
|
||||||
|
agent_id = create_agent(
|
||||||
|
name='test-sensor',
|
||||||
|
base_url='http://192.168.1.50:8020',
|
||||||
|
api_key='test-key',
|
||||||
|
description='Test sensor node',
|
||||||
|
capabilities={'adsb': True, 'wifi': True},
|
||||||
|
gps_coords={'lat': 40.7128, 'lon': -74.0060}
|
||||||
|
)
|
||||||
|
return agent_id
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent CRUD Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAgentCRUD:
|
||||||
|
"""Tests for agent CRUD operations."""
|
||||||
|
|
||||||
|
def test_list_agents_empty(self, client):
|
||||||
|
"""GET /controller/agents should return empty list initially."""
|
||||||
|
response = client.get('/controller/agents')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['agents'] == []
|
||||||
|
assert data['count'] == 0
|
||||||
|
|
||||||
|
def test_register_agent_success(self, client):
|
||||||
|
"""POST /controller/agents should register new agent."""
|
||||||
|
with patch('routes.controller.AgentClient') as MockClient:
|
||||||
|
# Mock successful capability fetch
|
||||||
|
mock_instance = Mock()
|
||||||
|
mock_instance.get_capabilities.return_value = {
|
||||||
|
'modes': {'adsb': True, 'wifi': True},
|
||||||
|
'devices': [{'name': 'RTL-SDR'}]
|
||||||
|
}
|
||||||
|
MockClient.return_value = mock_instance
|
||||||
|
|
||||||
|
response = client.post('/controller/agents',
|
||||||
|
json={
|
||||||
|
'name': 'new-sensor',
|
||||||
|
'base_url': 'http://192.168.1.51:8020',
|
||||||
|
'api_key': 'secret123',
|
||||||
|
'description': 'New sensor node'
|
||||||
|
},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['agent']['name'] == 'new-sensor'
|
||||||
|
|
||||||
|
def test_register_agent_missing_name(self, client):
|
||||||
|
"""POST /controller/agents should reject missing name."""
|
||||||
|
response = client.post('/controller/agents',
|
||||||
|
json={'base_url': 'http://localhost:8020'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'name is required' in data['message']
|
||||||
|
|
||||||
|
def test_register_agent_missing_url(self, client):
|
||||||
|
"""POST /controller/agents should reject missing URL."""
|
||||||
|
response = client.post('/controller/agents',
|
||||||
|
json={'name': 'test-sensor'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'Base URL is required' in data['message']
|
||||||
|
|
||||||
|
def test_register_agent_duplicate_name(self, client, sample_agent):
|
||||||
|
"""POST /controller/agents should reject duplicate name."""
|
||||||
|
response = client.post('/controller/agents',
|
||||||
|
json={
|
||||||
|
'name': 'test-sensor', # Same as sample_agent
|
||||||
|
'base_url': 'http://192.168.1.60:8020'
|
||||||
|
},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'already exists' in data['message']
|
||||||
|
|
||||||
|
def test_list_agents_with_agents(self, client, sample_agent):
|
||||||
|
"""GET /controller/agents should return registered agents."""
|
||||||
|
response = client.get('/controller/agents')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['count'] >= 1
|
||||||
|
|
||||||
|
names = [a['name'] for a in data['agents']]
|
||||||
|
assert 'test-sensor' in names
|
||||||
|
|
||||||
|
def test_get_agent_detail(self, client, sample_agent):
|
||||||
|
"""GET /controller/agents/<id> should return agent details."""
|
||||||
|
response = client.get(f'/controller/agents/{sample_agent}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['agent']['name'] == 'test-sensor'
|
||||||
|
assert data['agent']['capabilities']['adsb'] is True
|
||||||
|
|
||||||
|
def test_get_agent_not_found(self, client):
|
||||||
|
"""GET /controller/agents/<id> should return 404 for missing agent."""
|
||||||
|
response = client.get('/controller/agents/99999')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_agent(self, client, sample_agent):
|
||||||
|
"""PATCH /controller/agents/<id> should update agent."""
|
||||||
|
response = client.patch(f'/controller/agents/{sample_agent}',
|
||||||
|
json={'description': 'Updated description'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['agent']['description'] == 'Updated description'
|
||||||
|
|
||||||
|
def test_delete_agent(self, client, sample_agent):
|
||||||
|
"""DELETE /controller/agents/<id> should remove agent."""
|
||||||
|
response = client.delete(f'/controller/agents/{sample_agent}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify deleted
|
||||||
|
response = client.get(f'/controller/agents/{sample_agent}')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Proxy Operation Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestProxyOperations:
|
||||||
|
"""Tests for proxying operations to agents."""
|
||||||
|
|
||||||
|
def test_proxy_start_mode(self, client, sample_agent):
|
||||||
|
"""POST /controller/agents/<id>/<mode>/start should proxy to agent."""
|
||||||
|
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.start_mode.return_value = {'status': 'started', 'mode': 'adsb'}
|
||||||
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f'/controller/agents/{sample_agent}/adsb/start',
|
||||||
|
json={'device_index': 0},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['mode'] == 'adsb'
|
||||||
|
|
||||||
|
mock_client.start_mode.assert_called_once_with('adsb', {'device_index': 0})
|
||||||
|
|
||||||
|
def test_proxy_stop_mode(self, client, sample_agent):
|
||||||
|
"""POST /controller/agents/<id>/<mode>/stop should proxy to agent."""
|
||||||
|
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.stop_mode.return_value = {'status': 'stopped'}
|
||||||
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f'/controller/agents/{sample_agent}/wifi/stop',
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
|
||||||
|
def test_proxy_get_mode_data(self, client, sample_agent):
|
||||||
|
"""GET /controller/agents/<id>/<mode>/data should return data."""
|
||||||
|
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.get_mode_data.return_value = {
|
||||||
|
'mode': 'adsb',
|
||||||
|
'data': [{'icao': 'ABC123'}]
|
||||||
|
}
|
||||||
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
|
response = client.get(f'/controller/agents/{sample_agent}/adsb/data')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert 'agent_name' in data
|
||||||
|
assert data['agent_name'] == 'test-sensor'
|
||||||
|
|
||||||
|
def test_proxy_agent_not_found(self, client):
|
||||||
|
"""Proxy operations should return 404 for missing agent."""
|
||||||
|
response = client.post('/controller/agents/99999/adsb/start')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_proxy_connection_error(self, client, sample_agent):
|
||||||
|
"""Proxy should return 503 when agent unreachable."""
|
||||||
|
from utils.agent_client import AgentConnectionError
|
||||||
|
|
||||||
|
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.start_mode.side_effect = AgentConnectionError("Connection refused")
|
||||||
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f'/controller/agents/{sample_agent}/adsb/start',
|
||||||
|
json={},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'Cannot connect' in data['message']
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Push Data Ingestion Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestPushIngestion:
|
||||||
|
"""Tests for push data ingestion endpoint."""
|
||||||
|
|
||||||
|
def test_ingest_success(self, client, sample_agent):
|
||||||
|
"""POST /controller/api/ingest should store payload."""
|
||||||
|
payload = {
|
||||||
|
'agent_name': 'test-sensor',
|
||||||
|
'scan_type': 'adsb',
|
||||||
|
'interface': 'rtlsdr0',
|
||||||
|
'payload': {
|
||||||
|
'aircraft': [{'icao': 'ABC123', 'altitude': 35000}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post('/controller/api/ingest',
|
||||||
|
json=payload,
|
||||||
|
headers={'X-API-Key': 'test-key'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'accepted'
|
||||||
|
assert 'payload_id' in data
|
||||||
|
|
||||||
|
def test_ingest_unknown_agent(self, client):
|
||||||
|
"""POST /controller/api/ingest should reject unknown agent."""
|
||||||
|
payload = {
|
||||||
|
'agent_name': 'nonexistent-sensor',
|
||||||
|
'scan_type': 'adsb',
|
||||||
|
'payload': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post('/controller/api/ingest',
|
||||||
|
json=payload,
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'Unknown agent' in data['message']
|
||||||
|
|
||||||
|
def test_ingest_invalid_api_key(self, client, sample_agent):
|
||||||
|
"""POST /controller/api/ingest should reject invalid API key."""
|
||||||
|
payload = {
|
||||||
|
'agent_name': 'test-sensor',
|
||||||
|
'scan_type': 'adsb',
|
||||||
|
'payload': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post('/controller/api/ingest',
|
||||||
|
json=payload,
|
||||||
|
headers={'X-API-Key': 'wrong-key'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'Invalid API key' in data['message']
|
||||||
|
|
||||||
|
def test_ingest_missing_agent_name(self, client):
|
||||||
|
"""POST /controller/api/ingest should require agent_name."""
|
||||||
|
response = client.post('/controller/api/ingest',
|
||||||
|
json={'scan_type': 'adsb', 'payload': {}},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'agent_name required' in data['message']
|
||||||
|
|
||||||
|
def test_get_payloads(self, client, sample_agent):
|
||||||
|
"""GET /controller/api/payloads should return stored payloads."""
|
||||||
|
# First ingest some data
|
||||||
|
for i in range(3):
|
||||||
|
client.post('/controller/api/ingest',
|
||||||
|
json={
|
||||||
|
'agent_name': 'test-sensor',
|
||||||
|
'scan_type': 'adsb',
|
||||||
|
'payload': {'aircraft': [{'icao': f'TEST{i}'}]}
|
||||||
|
},
|
||||||
|
headers={'X-API-Key': 'test-key'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f'/controller/api/payloads?agent_id={sample_agent}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['count'] == 3
|
||||||
|
|
||||||
|
def test_get_payloads_filter_by_type(self, client, sample_agent):
|
||||||
|
"""GET /controller/api/payloads should filter by scan_type."""
|
||||||
|
# Ingest mixed data
|
||||||
|
client.post('/controller/api/ingest',
|
||||||
|
json={'agent_name': 'test-sensor', 'scan_type': 'adsb', 'payload': {}},
|
||||||
|
headers={'X-API-Key': 'test-key'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
client.post('/controller/api/ingest',
|
||||||
|
json={'agent_name': 'test-sensor', 'scan_type': 'wifi', 'payload': {}},
|
||||||
|
headers={'X-API-Key': 'test-key'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get('/controller/api/payloads?scan_type=adsb')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert all(p['scan_type'] == 'adsb' for p in data['payloads'])
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Location Estimation Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestLocationEstimation:
|
||||||
|
"""Tests for device location estimation (trilateration)."""
|
||||||
|
|
||||||
|
def test_add_observation(self, client):
|
||||||
|
"""POST /controller/api/location/observe should accept observation."""
|
||||||
|
response = client.post('/controller/api/location/observe',
|
||||||
|
json={
|
||||||
|
'device_id': 'AA:BB:CC:DD:EE:FF',
|
||||||
|
'agent_name': 'sensor-1',
|
||||||
|
'agent_lat': 40.7128,
|
||||||
|
'agent_lon': -74.0060,
|
||||||
|
'rssi': -55
|
||||||
|
},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['device_id'] == 'AA:BB:CC:DD:EE:FF'
|
||||||
|
|
||||||
|
def test_add_observation_missing_fields(self, client):
|
||||||
|
"""POST /controller/api/location/observe should require all fields."""
|
||||||
|
response = client.post('/controller/api/location/observe',
|
||||||
|
json={
|
||||||
|
'device_id': 'AA:BB:CC:DD:EE:FF',
|
||||||
|
'rssi': -55
|
||||||
|
# Missing agent_name, agent_lat, agent_lon
|
||||||
|
},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_estimate_location(self, client):
|
||||||
|
"""POST /controller/api/location/estimate should compute location."""
|
||||||
|
response = client.post('/controller/api/location/estimate',
|
||||||
|
json={
|
||||||
|
'observations': [
|
||||||
|
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'},
|
||||||
|
{'agent_lat': 40.7135, 'agent_lon': -74.0055, 'rssi': -70, 'agent_name': 'node-2'},
|
||||||
|
{'agent_lat': 40.7120, 'agent_lon': -74.0050, 'rssi': -62, 'agent_name': 'node-3'}
|
||||||
|
],
|
||||||
|
'environment': 'outdoor'
|
||||||
|
},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
# Should have computed a location
|
||||||
|
if data['location']:
|
||||||
|
assert 'lat' in data['location']
|
||||||
|
assert 'lon' in data['location']
|
||||||
|
|
||||||
|
def test_estimate_location_insufficient_data(self, client):
|
||||||
|
"""Estimation should require at least 2 observations."""
|
||||||
|
response = client.post('/controller/api/location/estimate',
|
||||||
|
json={
|
||||||
|
'observations': [
|
||||||
|
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'At least 2' in data['message']
|
||||||
|
|
||||||
|
def test_get_device_location_not_found(self, client):
|
||||||
|
"""GET /controller/api/location/<device_id> returns not_found for unknown device."""
|
||||||
|
response = client.get('/controller/api/location/unknown-device')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'not_found'
|
||||||
|
assert data['location'] is None
|
||||||
|
|
||||||
|
def test_get_all_locations(self, client):
|
||||||
|
"""GET /controller/api/location/all should return all estimates."""
|
||||||
|
response = client.get('/controller/api/location/all')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert 'devices' in data
|
||||||
|
|
||||||
|
def test_get_devices_near(self, client):
|
||||||
|
"""GET /controller/api/location/near should find nearby devices."""
|
||||||
|
response = client.get(
|
||||||
|
'/controller/api/location/near',
|
||||||
|
query_string={'lat': 40.7128, 'lon': -74.0060, 'radius': 100}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['center']['lat'] == 40.7128
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent Refresh Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAgentRefresh:
|
||||||
|
"""Tests for agent refresh operations."""
|
||||||
|
|
||||||
|
def test_refresh_agent_success(self, client, sample_agent):
|
||||||
|
"""POST /controller/agents/<id>/refresh should update metadata."""
|
||||||
|
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.refresh_metadata.return_value = {
|
||||||
|
'healthy': True,
|
||||||
|
'capabilities': {
|
||||||
|
'modes': {'adsb': True, 'wifi': True, 'bluetooth': True},
|
||||||
|
'devices': [{'name': 'RTL-SDR V3'}]
|
||||||
|
},
|
||||||
|
'status': {'running_modes': ['adsb']},
|
||||||
|
'config': {}
|
||||||
|
}
|
||||||
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
|
response = client.post(f'/controller/agents/{sample_agent}/refresh')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['metadata']['healthy'] is True
|
||||||
|
|
||||||
|
def test_refresh_agent_unreachable(self, client, sample_agent):
|
||||||
|
"""POST /controller/agents/<id>/refresh should return 503 if unreachable."""
|
||||||
|
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.refresh_metadata.return_value = {'healthy': False}
|
||||||
|
mock_create.return_value = mock_client
|
||||||
|
|
||||||
|
response = client.post(f'/controller/agents/{sample_agent}/refresh')
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SSE Stream Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestSSEStream:
|
||||||
|
"""Tests for SSE streaming endpoint."""
|
||||||
|
|
||||||
|
def test_stream_all_endpoint_exists(self, client):
|
||||||
|
"""GET /controller/stream/all should exist and return SSE."""
|
||||||
|
# Just verify the endpoint is accessible
|
||||||
|
# Full SSE testing requires more complex setup
|
||||||
|
response = client.get('/controller/stream/all')
|
||||||
|
assert response.content_type == 'text/event-stream'
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""Tests for Meshtastic integration.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- MeshtasticClient initialization and state management
|
||||||
|
- PSK parsing (various formats)
|
||||||
|
- Message callback handling
|
||||||
|
- Route endpoints (mocked)
|
||||||
|
- Graceful degradation when SDK not installed
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Utility Module Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestMeshtasticAvailability:
|
||||||
|
"""Tests for SDK availability checks."""
|
||||||
|
|
||||||
|
def test_is_meshtastic_available_returns_bool(self):
|
||||||
|
"""is_meshtastic_available should return a boolean."""
|
||||||
|
from utils.meshtastic import is_meshtastic_available
|
||||||
|
result = is_meshtastic_available()
|
||||||
|
assert isinstance(result, bool)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeshtasticMessage:
|
||||||
|
"""Tests for MeshtasticMessage dataclass."""
|
||||||
|
|
||||||
|
def test_message_to_dict(self):
|
||||||
|
"""MeshtasticMessage should convert to dictionary."""
|
||||||
|
from utils.meshtastic import MeshtasticMessage
|
||||||
|
|
||||||
|
msg = MeshtasticMessage(
|
||||||
|
from_id='!a1b2c3d4',
|
||||||
|
to_id='^all',
|
||||||
|
message='Hello mesh!',
|
||||||
|
portnum='TEXT_MESSAGE_APP',
|
||||||
|
channel=0,
|
||||||
|
rssi=-95,
|
||||||
|
snr=-3.5,
|
||||||
|
hop_limit=3,
|
||||||
|
timestamp=datetime(2026, 1, 27, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
d = msg.to_dict()
|
||||||
|
|
||||||
|
assert d['type'] == 'meshtastic'
|
||||||
|
assert d['from'] == '!a1b2c3d4'
|
||||||
|
assert d['to'] == '^all'
|
||||||
|
assert d['message'] == 'Hello mesh!'
|
||||||
|
assert d['portnum'] == 'TEXT_MESSAGE_APP'
|
||||||
|
assert d['channel'] == 0
|
||||||
|
assert d['rssi'] == -95
|
||||||
|
assert d['snr'] == -3.5
|
||||||
|
assert d['hop_limit'] == 3
|
||||||
|
assert '2026-01-27' in d['timestamp']
|
||||||
|
|
||||||
|
def test_message_with_none_values(self):
|
||||||
|
"""MeshtasticMessage should handle None values."""
|
||||||
|
from utils.meshtastic import MeshtasticMessage
|
||||||
|
|
||||||
|
msg = MeshtasticMessage(
|
||||||
|
from_id='!00000001',
|
||||||
|
to_id='!00000002',
|
||||||
|
message=None,
|
||||||
|
portnum='POSITION_APP',
|
||||||
|
channel=1,
|
||||||
|
rssi=None,
|
||||||
|
snr=None,
|
||||||
|
hop_limit=None,
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
d = msg.to_dict()
|
||||||
|
|
||||||
|
assert d['message'] is None
|
||||||
|
assert d['rssi'] is None
|
||||||
|
assert d['snr'] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelConfig:
|
||||||
|
"""Tests for ChannelConfig dataclass."""
|
||||||
|
|
||||||
|
def test_channel_to_dict_hides_psk(self):
|
||||||
|
"""ChannelConfig.to_dict should not expose raw PSK."""
|
||||||
|
from utils.meshtastic import ChannelConfig
|
||||||
|
|
||||||
|
config = ChannelConfig(
|
||||||
|
index=0,
|
||||||
|
name='Primary',
|
||||||
|
psk=b'\x01\x02\x03\x04' * 8, # 32-byte key
|
||||||
|
role=1, # PRIMARY
|
||||||
|
)
|
||||||
|
|
||||||
|
d = config.to_dict()
|
||||||
|
|
||||||
|
assert 'psk' not in d # Raw PSK should not be in dict
|
||||||
|
assert d['index'] == 0
|
||||||
|
assert d['name'] == 'Primary'
|
||||||
|
assert d['role'] == 'PRIMARY'
|
||||||
|
assert d['encrypted'] is True
|
||||||
|
assert d['key_type'] == 'AES-256'
|
||||||
|
|
||||||
|
def test_channel_default_key_detection(self):
|
||||||
|
"""ChannelConfig should detect default key."""
|
||||||
|
from utils.meshtastic import ChannelConfig
|
||||||
|
|
||||||
|
# Default key is single byte 0x01
|
||||||
|
config = ChannelConfig(index=0, name='Test', psk=b'\x01', role=1)
|
||||||
|
d = config.to_dict()
|
||||||
|
|
||||||
|
assert d['is_default_key'] is True
|
||||||
|
assert d['key_type'] == 'default'
|
||||||
|
|
||||||
|
def test_channel_aes128_detection(self):
|
||||||
|
"""ChannelConfig should detect AES-128 key."""
|
||||||
|
from utils.meshtastic import ChannelConfig
|
||||||
|
|
||||||
|
config = ChannelConfig(index=0, name='Test', psk=b'0' * 16, role=1)
|
||||||
|
d = config.to_dict()
|
||||||
|
|
||||||
|
assert d['key_type'] == 'AES-128'
|
||||||
|
assert d['encrypted'] is True
|
||||||
|
|
||||||
|
def test_channel_no_encryption(self):
|
||||||
|
"""ChannelConfig should detect no encryption."""
|
||||||
|
from utils.meshtastic import ChannelConfig
|
||||||
|
|
||||||
|
config = ChannelConfig(index=0, name='Test', psk=b'', role=1)
|
||||||
|
d = config.to_dict()
|
||||||
|
|
||||||
|
assert d['key_type'] == 'none'
|
||||||
|
assert d['encrypted'] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPSKParsing:
|
||||||
|
"""Tests for PSK format parsing."""
|
||||||
|
|
||||||
|
def test_parse_psk_none(self):
|
||||||
|
"""Should parse 'none' as empty bytes."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
|
||||||
|
client = MeshtasticClient()
|
||||||
|
result = client._parse_psk('none')
|
||||||
|
|
||||||
|
assert result == b''
|
||||||
|
|
||||||
|
def test_parse_psk_default(self):
|
||||||
|
"""Should parse 'default' as single byte."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
|
||||||
|
client = MeshtasticClient()
|
||||||
|
result = client._parse_psk('default')
|
||||||
|
|
||||||
|
assert result == b'\x01'
|
||||||
|
|
||||||
|
def test_parse_psk_random(self):
|
||||||
|
"""Should generate 32 random bytes for 'random'."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
|
||||||
|
client = MeshtasticClient()
|
||||||
|
result = client._parse_psk('random')
|
||||||
|
|
||||||
|
assert len(result) == 32
|
||||||
|
# Verify it's actually random (two calls should differ)
|
||||||
|
result2 = client._parse_psk('random')
|
||||||
|
assert result != result2
|
||||||
|
|
||||||
|
def test_parse_psk_base64(self):
|
||||||
|
"""Should decode base64 PSK."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
import base64
|
||||||
|
|
||||||
|
client = MeshtasticClient()
|
||||||
|
# 32-byte key encoded as base64
|
||||||
|
key = b'A' * 32
|
||||||
|
encoded = 'base64:' + base64.b64encode(key).decode()
|
||||||
|
|
||||||
|
result = client._parse_psk(encoded)
|
||||||
|
|
||||||
|
assert result == key
|
||||||
|
|
||||||
|
def test_parse_psk_hex(self):
|
||||||
|
"""Should decode hex PSK."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
|
||||||
|
client = MeshtasticClient()
|
||||||
|
# 16-byte key as hex
|
||||||
|
result = client._parse_psk('0x' + '41' * 16)
|
||||||
|
|
||||||
|
assert result == b'A' * 16
|
||||||
|
|
||||||
|
def test_parse_psk_simple_passphrase(self):
|
||||||
|
"""Should hash simple passphrase to 32-byte key."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
client = MeshtasticClient()
|
||||||
|
result = client._parse_psk('simple:MySecretPassword')
|
||||||
|
|
||||||
|
expected = hashlib.sha256(b'MySecretPassword').digest()
|
||||||
|
assert result == expected
|
||||||
|
assert len(result) == 32
|
||||||
|
|
||||||
|
def test_parse_psk_invalid(self):
|
||||||
|
"""Should return None for invalid PSK format."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
|
||||||
|
client = MeshtasticClient()
|
||||||
|
|
||||||
|
assert client._parse_psk('base64:!!!invalid!!!') is None
|
||||||
|
assert client._parse_psk('0xZZZZ') is None
|
||||||
|
|
||||||
|
def test_parse_psk_raw_base64(self):
|
||||||
|
"""Should accept raw base64 without prefix."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
import base64
|
||||||
|
|
||||||
|
client = MeshtasticClient()
|
||||||
|
key = b'B' * 16
|
||||||
|
encoded = base64.b64encode(key).decode()
|
||||||
|
|
||||||
|
result = client._parse_psk(encoded)
|
||||||
|
|
||||||
|
assert result == key
|
||||||
|
|
||||||
|
|
||||||
|
class TestNodeIdFormatting:
|
||||||
|
"""Tests for node ID formatting."""
|
||||||
|
|
||||||
|
def test_format_regular_node(self):
|
||||||
|
"""Should format regular node as hex."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
|
||||||
|
result = MeshtasticClient._format_node_id(0xDEADBEEF)
|
||||||
|
|
||||||
|
assert result == '!deadbeef'
|
||||||
|
|
||||||
|
def test_format_broadcast(self):
|
||||||
|
"""Should format broadcast address."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
|
||||||
|
result = MeshtasticClient._format_node_id(0xFFFFFFFF)
|
||||||
|
|
||||||
|
assert result == '^all'
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Route Tests (Mocked)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestMeshtasticRoutes:
|
||||||
|
"""Tests for Flask route endpoints."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(self):
|
||||||
|
"""Create Flask test app."""
|
||||||
|
from flask import Flask
|
||||||
|
from routes.meshtastic import meshtastic_bp
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.register_blueprint(meshtastic_bp)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, app):
|
||||||
|
"""Create test client."""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
def test_status_sdk_not_installed(self, client):
|
||||||
|
"""GET /meshtastic/status should report SDK unavailable."""
|
||||||
|
with patch('routes.meshtastic.is_meshtastic_available', return_value=False):
|
||||||
|
response = client.get('/meshtastic/status')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert data['available'] is False
|
||||||
|
assert 'not installed' in data['error']
|
||||||
|
|
||||||
|
def test_status_not_connected(self, client):
|
||||||
|
"""GET /meshtastic/status should report not running when disconnected."""
|
||||||
|
with patch('routes.meshtastic.is_meshtastic_available', return_value=True):
|
||||||
|
with patch('routes.meshtastic.get_meshtastic_client', return_value=None):
|
||||||
|
response = client.get('/meshtastic/status')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert data['available'] is True
|
||||||
|
assert data['running'] is False
|
||||||
|
|
||||||
|
def test_start_sdk_not_installed(self, client):
|
||||||
|
"""POST /meshtastic/start should fail if SDK not installed."""
|
||||||
|
with patch('routes.meshtastic.is_meshtastic_available', return_value=False):
|
||||||
|
response = client.post('/meshtastic/start')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
def test_stop_always_succeeds(self, client):
|
||||||
|
"""POST /meshtastic/stop should always succeed."""
|
||||||
|
with patch('routes.meshtastic.stop_meshtastic'):
|
||||||
|
response = client.post('/meshtastic/stop')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert data['status'] == 'stopped'
|
||||||
|
|
||||||
|
def test_channels_not_connected(self, client):
|
||||||
|
"""GET /meshtastic/channels should fail if not connected."""
|
||||||
|
with patch('routes.meshtastic.get_meshtastic_client', return_value=None):
|
||||||
|
response = client.get('/meshtastic/channels')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'Not connected' in data['message']
|
||||||
|
|
||||||
|
def test_configure_channel_invalid_index(self, client):
|
||||||
|
"""POST /meshtastic/channels/<id> should reject invalid index."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.is_running = True
|
||||||
|
|
||||||
|
with patch('routes.meshtastic.get_meshtastic_client', return_value=mock_client):
|
||||||
|
response = client.post(
|
||||||
|
'/meshtastic/channels/10',
|
||||||
|
json={'name': 'Test'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'must be 0-7' in data['message']
|
||||||
|
|
||||||
|
def test_configure_channel_no_params(self, client):
|
||||||
|
"""POST /meshtastic/channels/<id> should require name or psk."""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.is_running = True
|
||||||
|
|
||||||
|
with patch('routes.meshtastic.get_meshtastic_client', return_value=mock_client):
|
||||||
|
response = client.post(
|
||||||
|
'/meshtastic/channels/0',
|
||||||
|
json={},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'Must provide' in data['message']
|
||||||
|
|
||||||
|
def test_messages_empty(self, client):
|
||||||
|
"""GET /meshtastic/messages should return empty list initially."""
|
||||||
|
with patch('routes.meshtastic._recent_messages', []):
|
||||||
|
response = client.get('/meshtastic/messages')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert data['messages'] == []
|
||||||
|
assert data['count'] == 0
|
||||||
|
|
||||||
|
def test_messages_with_limit(self, client):
|
||||||
|
"""GET /meshtastic/messages should respect limit param."""
|
||||||
|
test_messages = [{'id': i} for i in range(10)]
|
||||||
|
|
||||||
|
with patch('routes.meshtastic._recent_messages', test_messages):
|
||||||
|
response = client.get('/meshtastic/messages?limit=3')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(data['messages']) == 3
|
||||||
|
# Should return last 3 (most recent)
|
||||||
|
assert data['messages'][0]['id'] == 7
|
||||||
|
|
||||||
|
def test_messages_filter_by_channel(self, client):
|
||||||
|
"""GET /meshtastic/messages should filter by channel."""
|
||||||
|
test_messages = [
|
||||||
|
{'id': 1, 'channel': 0},
|
||||||
|
{'id': 2, 'channel': 1},
|
||||||
|
{'id': 3, 'channel': 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch('routes.meshtastic._recent_messages', test_messages):
|
||||||
|
response = client.get('/meshtastic/messages?channel=0')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(data['messages']) == 2
|
||||||
|
assert all(m['channel'] == 0 for m in data['messages'])
|
||||||
|
|
||||||
|
def test_stream_endpoint_exists(self, client):
|
||||||
|
"""GET /meshtastic/stream should return SSE content type."""
|
||||||
|
response = client.get('/meshtastic/stream')
|
||||||
|
|
||||||
|
assert response.content_type == 'text/event-stream'
|
||||||
|
|
||||||
|
def test_node_not_connected(self, client):
|
||||||
|
"""GET /meshtastic/node should fail if not connected."""
|
||||||
|
with patch('routes.meshtastic.get_meshtastic_client', return_value=None):
|
||||||
|
response = client.get('/meshtastic/node')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'Not connected' in data['message']
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Integration Tests (Mocked SDK)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestMeshtasticClientMocked:
|
||||||
|
"""Tests for MeshtasticClient with mocked SDK."""
|
||||||
|
|
||||||
|
def test_client_init(self):
|
||||||
|
"""MeshtasticClient should initialize with default state."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
|
||||||
|
client = MeshtasticClient()
|
||||||
|
|
||||||
|
assert client.is_running is False
|
||||||
|
assert client.device_path is None
|
||||||
|
assert client.error is None
|
||||||
|
|
||||||
|
def test_client_connect_no_sdk(self):
|
||||||
|
"""MeshtasticClient.connect should fail gracefully without SDK."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
|
||||||
|
with patch('utils.meshtastic.HAS_MESHTASTIC', False):
|
||||||
|
client = MeshtasticClient()
|
||||||
|
result = client.connect()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert 'not installed' in client.error
|
||||||
|
|
||||||
|
def test_client_disconnect_idempotent(self):
|
||||||
|
"""MeshtasticClient.disconnect should be safe to call multiple times."""
|
||||||
|
from utils.meshtastic import MeshtasticClient
|
||||||
|
|
||||||
|
client = MeshtasticClient()
|
||||||
|
|
||||||
|
# Should not raise even when not connected
|
||||||
|
client.disconnect()
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
|
assert client.is_running is False
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""
|
||||||
|
HTTP client for communicating with remote Intercept agents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.agent_client')
|
||||||
|
|
||||||
|
|
||||||
|
class AgentHTTPError(RuntimeError):
|
||||||
|
"""Exception raised when agent HTTP request fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class AgentConnectionError(AgentHTTPError):
|
||||||
|
"""Exception raised when agent is unreachable."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AgentClient:
|
||||||
|
"""HTTP client for communicating with a remote Intercept agent."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str | None = None,
|
||||||
|
timeout: float = 60.0
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize agent client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL of the agent (e.g., http://192.168.1.50:8020)
|
||||||
|
api_key: Optional API key for authentication
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
"""
|
||||||
|
self.base_url = base_url.rstrip('/')
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
"""Get request headers."""
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if self.api_key:
|
||||||
|
headers['X-API-Key'] = self.api_key
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def _get(self, path: str, params: dict | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
Perform GET request to agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: URL path (e.g., /capabilities)
|
||||||
|
params: Optional query parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentHTTPError: On HTTP errors
|
||||||
|
AgentConnectionError: If agent is unreachable
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
url,
|
||||||
|
headers=self._headers(),
|
||||||
|
params=params,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json() if response.content else {}
|
||||||
|
except requests.ConnectionError as e:
|
||||||
|
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
||||||
|
except requests.Timeout:
|
||||||
|
raise AgentConnectionError(f"Request to agent timed out after {self.timeout}s")
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
# Try to extract error message from response body
|
||||||
|
error_msg = f"Agent returned error: {e.response.status_code}"
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
if 'message' in error_data:
|
||||||
|
error_msg = error_data['message']
|
||||||
|
elif 'error' in error_data:
|
||||||
|
error_msg = error_data['error']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise AgentHTTPError(error_msg, status_code=e.response.status_code)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise AgentHTTPError(f"Request failed: {e}")
|
||||||
|
|
||||||
|
def _post(self, path: str, data: dict | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
Perform POST request to agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: URL path (e.g., /sensor/start)
|
||||||
|
data: Optional JSON body
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentHTTPError: On HTTP errors
|
||||||
|
AgentConnectionError: If agent is unreachable
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
json=data or {},
|
||||||
|
headers=self._headers(),
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json() if response.content else {}
|
||||||
|
except requests.ConnectionError as e:
|
||||||
|
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
||||||
|
except requests.Timeout:
|
||||||
|
raise AgentConnectionError(f"Request to agent timed out after {self.timeout}s")
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
# Try to extract error message from response body
|
||||||
|
error_msg = f"Agent returned error: {e.response.status_code}"
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
if 'message' in error_data:
|
||||||
|
error_msg = error_data['message']
|
||||||
|
elif 'error' in error_data:
|
||||||
|
error_msg = error_data['error']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise AgentHTTPError(error_msg, status_code=e.response.status_code)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise AgentHTTPError(f"Request failed: {e}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Capability & Status
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_capabilities(self) -> dict:
|
||||||
|
"""
|
||||||
|
Get agent capabilities (available modes, devices).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'modes' (mode -> bool), 'devices' (list), 'agent_version'
|
||||||
|
"""
|
||||||
|
return self._get('/capabilities')
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""
|
||||||
|
Get agent status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'running_modes', 'uptime', 'push_enabled', etc.
|
||||||
|
"""
|
||||||
|
return self._get('/status')
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if agent is healthy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if agent is reachable and healthy
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = self._get('/health')
|
||||||
|
return result.get('status') == 'healthy'
|
||||||
|
except (AgentHTTPError, AgentConnectionError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_config(self) -> dict:
|
||||||
|
"""Get agent configuration (non-sensitive fields)."""
|
||||||
|
return self._get('/config')
|
||||||
|
|
||||||
|
def update_config(self, **kwargs) -> dict:
|
||||||
|
"""
|
||||||
|
Update agent configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
push_enabled: Enable/disable push mode
|
||||||
|
push_interval: Push interval in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated config
|
||||||
|
"""
|
||||||
|
return self._post('/config', kwargs)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Mode Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def start_mode(self, mode: str, params: dict | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
Start a mode on the agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Mode name (e.g., 'sensor', 'adsb', 'wifi')
|
||||||
|
params: Mode-specific parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Start result with 'status' field
|
||||||
|
"""
|
||||||
|
return self._post(f'/{mode}/start', params or {})
|
||||||
|
|
||||||
|
def stop_mode(self, mode: str) -> dict:
|
||||||
|
"""
|
||||||
|
Stop a running mode on the agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Mode name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stop result with 'status' field
|
||||||
|
"""
|
||||||
|
return self._post(f'/{mode}/stop')
|
||||||
|
|
||||||
|
def get_mode_status(self, mode: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get status of a specific mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Mode name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mode status with 'running' field
|
||||||
|
"""
|
||||||
|
return self._get(f'/{mode}/status')
|
||||||
|
|
||||||
|
def get_mode_data(self, mode: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get current data snapshot for a mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Mode name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Data snapshot with 'data' field
|
||||||
|
"""
|
||||||
|
return self._get(f'/{mode}/data')
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Convenience Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def refresh_metadata(self) -> dict:
|
||||||
|
"""
|
||||||
|
Fetch comprehensive metadata from agent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with capabilities, status, and config
|
||||||
|
"""
|
||||||
|
metadata = {
|
||||||
|
'capabilities': None,
|
||||||
|
'status': None,
|
||||||
|
'config': None,
|
||||||
|
'healthy': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata['capabilities'] = self.get_capabilities()
|
||||||
|
metadata['status'] = self.get_status()
|
||||||
|
metadata['config'] = self.get_config()
|
||||||
|
metadata['healthy'] = True
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
logger.warning(f"Failed to refresh agent metadata: {e}")
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"AgentClient({self.base_url})"
|
||||||
|
|
||||||
|
|
||||||
|
def create_client_from_agent(agent: dict) -> AgentClient:
|
||||||
|
"""
|
||||||
|
Create an AgentClient from an agent database record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: Agent dict from database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured AgentClient
|
||||||
|
"""
|
||||||
|
return AgentClient(
|
||||||
|
base_url=agent['base_url'],
|
||||||
|
api_key=agent.get('api_key'),
|
||||||
|
timeout=60.0
|
||||||
|
)
|
||||||
@@ -238,6 +238,9 @@ def _check_fallback_tools(caps: SystemCapabilities) -> None:
|
|||||||
# Check btmgmt
|
# Check btmgmt
|
||||||
caps.has_btmgmt = shutil.which('btmgmt') is not None
|
caps.has_btmgmt = shutil.which('btmgmt') is not None
|
||||||
|
|
||||||
|
# Check ubertooth tools (Ubertooth One hardware)
|
||||||
|
caps.has_ubertooth = shutil.which('ubertooth-btle') is not None
|
||||||
|
|
||||||
# Check CAP_NET_ADMIN for non-root users
|
# Check CAP_NET_ADMIN for non-root users
|
||||||
if not caps.is_root:
|
if not caps.is_root:
|
||||||
_check_capabilities_permission(caps)
|
_check_capabilities_permission(caps)
|
||||||
|
|||||||
@@ -531,6 +531,16 @@ class FallbackScanner:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Try ubertooth (raw packet capture with Ubertooth One hardware)
|
||||||
|
try:
|
||||||
|
from .ubertooth_scanner import UbertoothScanner
|
||||||
|
self._active_scanner = UbertoothScanner(on_observation=self._on_observation)
|
||||||
|
if self._active_scanner.start():
|
||||||
|
self._backend = 'ubertooth'
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
logger.error("No fallback scanner available")
|
logger.error("No fallback scanner available")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ class SystemCapabilities:
|
|||||||
has_hcitool: bool = False
|
has_hcitool: bool = False
|
||||||
has_bluetoothctl: bool = False
|
has_bluetoothctl: bool = False
|
||||||
has_btmgmt: bool = False
|
has_btmgmt: bool = False
|
||||||
|
has_ubertooth: bool = False
|
||||||
|
|
||||||
# Recommended backend
|
# Recommended backend
|
||||||
recommended_backend: str = 'none'
|
recommended_backend: str = 'none'
|
||||||
@@ -421,7 +422,8 @@ class SystemCapabilities:
|
|||||||
(self.has_dbus and self.has_bluez and len(self.adapters) > 0) or
|
(self.has_dbus and self.has_bluez and len(self.adapters) > 0) or
|
||||||
self.has_bleak or
|
self.has_bleak or
|
||||||
self.has_hcitool or
|
self.has_hcitool or
|
||||||
self.has_bluetoothctl
|
self.has_bluetoothctl or
|
||||||
|
self.has_ubertooth
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
@@ -442,6 +444,7 @@ class SystemCapabilities:
|
|||||||
'has_hcitool': self.has_hcitool,
|
'has_hcitool': self.has_hcitool,
|
||||||
'has_bluetoothctl': self.has_bluetoothctl,
|
'has_bluetoothctl': self.has_bluetoothctl,
|
||||||
'has_btmgmt': self.has_btmgmt,
|
'has_btmgmt': self.has_btmgmt,
|
||||||
|
'has_ubertooth': self.has_ubertooth,
|
||||||
'preferred_backend': self.recommended_backend, # Alias for frontend
|
'preferred_backend': self.recommended_backend, # Alias for frontend
|
||||||
'recommended_backend': self.recommended_backend,
|
'recommended_backend': self.recommended_backend,
|
||||||
'issues': self.issues,
|
'issues': self.issues,
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""
|
||||||
|
Ubertooth One BLE scanner backend.
|
||||||
|
|
||||||
|
Uses ubertooth-btle for passive BLE packet capture across all 40 channels.
|
||||||
|
Provides enhanced sniffing capabilities compared to standard Bluetooth adapters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
ADDRESS_TYPE_PUBLIC,
|
||||||
|
ADDRESS_TYPE_RANDOM,
|
||||||
|
)
|
||||||
|
from .models import BTObservation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Ubertooth-specific timeout for subprocess operations
|
||||||
|
UBERTOOTH_STARTUP_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
class UbertoothScanner:
|
||||||
|
"""
|
||||||
|
BLE scanner using Ubertooth One hardware via ubertooth-btle.
|
||||||
|
|
||||||
|
Captures raw BLE advertisements passively across all 40 BLE channels.
|
||||||
|
Provides richer data than standard adapters including raw advertising payloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_index: int = 0,
|
||||||
|
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Ubertooth scanner.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_index: Ubertooth device index (for systems with multiple Ubertooths)
|
||||||
|
on_observation: Callback for each BLE observation
|
||||||
|
"""
|
||||||
|
self._device_index = device_index
|
||||||
|
self._on_observation = on_observation
|
||||||
|
self._process: Optional[subprocess.Popen] = None
|
||||||
|
self._is_scanning = False
|
||||||
|
self._reader_thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_available() -> bool:
|
||||||
|
"""Check if ubertooth-btle is available on the system."""
|
||||||
|
return shutil.which('ubertooth-btle') is not None
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""
|
||||||
|
Start Ubertooth BLE scanning.
|
||||||
|
|
||||||
|
Spawns ubertooth-btle in advertisement-only mode (-n flag).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if scanning started successfully, False otherwise.
|
||||||
|
"""
|
||||||
|
if not self.is_available():
|
||||||
|
logger.error("ubertooth-btle not found in PATH")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._is_scanning:
|
||||||
|
logger.warning("Ubertooth scanner already running")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build command: ubertooth-btle -n -U <device_index>
|
||||||
|
# -n = advertisements only (no follow mode)
|
||||||
|
# -U = device index for multiple Ubertooths
|
||||||
|
cmd = ['ubertooth-btle', '-n']
|
||||||
|
if self._device_index > 0:
|
||||||
|
cmd.extend(['-U', str(self._device_index)])
|
||||||
|
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1, # Line buffered
|
||||||
|
)
|
||||||
|
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._reader_thread = threading.Thread(
|
||||||
|
target=self._read_output,
|
||||||
|
daemon=True,
|
||||||
|
name='ubertooth-reader'
|
||||||
|
)
|
||||||
|
self._reader_thread.start()
|
||||||
|
self._is_scanning = True
|
||||||
|
logger.info(f"Ubertooth scanner started (device index: {self._device_index})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("ubertooth-btle not found")
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
logger.error("ubertooth-btle requires appropriate permissions (try running as root)")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start Ubertooth scanner: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop Ubertooth scanning and clean up resources."""
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
if self._process:
|
||||||
|
try:
|
||||||
|
self._process.terminate()
|
||||||
|
self._process.wait(timeout=2.0)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("Ubertooth process did not terminate, killing")
|
||||||
|
self._process.kill()
|
||||||
|
self._process.wait(timeout=1.0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping Ubertooth process: {e}")
|
||||||
|
finally:
|
||||||
|
self._process = None
|
||||||
|
|
||||||
|
if self._reader_thread:
|
||||||
|
self._reader_thread.join(timeout=2.0)
|
||||||
|
self._reader_thread = None
|
||||||
|
|
||||||
|
self._is_scanning = False
|
||||||
|
logger.info("Ubertooth scanner stopped")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scanning(self) -> bool:
|
||||||
|
"""Return whether the scanner is currently active."""
|
||||||
|
return self._is_scanning
|
||||||
|
|
||||||
|
def _read_output(self) -> None:
|
||||||
|
"""
|
||||||
|
Background thread to read and parse ubertooth-btle output.
|
||||||
|
|
||||||
|
Output format example:
|
||||||
|
systime=1349412883 freq=2402 addr=8e89bed6 delta_t=38.441 ms 00 17 ab cd ef 01 22 ...
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while not self._stop_event.is_set() and self._process:
|
||||||
|
line = self._process.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
# Process ended
|
||||||
|
break
|
||||||
|
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip non-packet lines (errors, status messages)
|
||||||
|
if not line.startswith('systime='):
|
||||||
|
# Log errors from stderr would go here if needed
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
observation = self._parse_advertisement(line)
|
||||||
|
if observation and self._on_observation:
|
||||||
|
self._on_observation(observation)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error parsing Ubertooth output: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ubertooth reader thread error: {e}")
|
||||||
|
finally:
|
||||||
|
self._is_scanning = False
|
||||||
|
|
||||||
|
def _parse_advertisement(self, line: str) -> Optional[BTObservation]:
|
||||||
|
"""
|
||||||
|
Parse a single ubertooth-btle output line into a BTObservation.
|
||||||
|
|
||||||
|
Format: systime=<epoch> freq=<mhz> addr=<access_addr> delta_t=<ms> ms <hex bytes...>
|
||||||
|
|
||||||
|
The hex bytes contain the BLE PDU:
|
||||||
|
- Byte 0: PDU type and header flags
|
||||||
|
- Byte 1: Length
|
||||||
|
- Bytes 2-7: Advertiser MAC address (reversed byte order)
|
||||||
|
- Remaining: Advertising data payload
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: Raw output line from ubertooth-btle
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BTObservation if successfully parsed, None otherwise.
|
||||||
|
"""
|
||||||
|
# Parse the structured prefix
|
||||||
|
# Example: systime=1349412883 freq=2402 addr=8e89bed6 delta_t=38.441 ms 00 17 ab cd ef ...
|
||||||
|
match = re.match(
|
||||||
|
r'systime=(\d+)\s+freq=(\d+)\s+addr=([0-9a-fA-F]+)\s+delta_t=[\d.]+\s+ms\s+(.+)',
|
||||||
|
line
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse hex bytes
|
||||||
|
hex_data = match.group(4).strip()
|
||||||
|
try:
|
||||||
|
raw_bytes = bytes.fromhex(hex_data.replace(' ', ''))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(raw_bytes) < 8:
|
||||||
|
# Need at least PDU header + MAC address
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse PDU header
|
||||||
|
pdu_type = raw_bytes[0] & 0x0F
|
||||||
|
# tx_add = (raw_bytes[0] >> 6) & 0x01 # TxAdd: 1 = random address
|
||||||
|
length = raw_bytes[1]
|
||||||
|
|
||||||
|
# Validate length
|
||||||
|
if len(raw_bytes) < 2 + length:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract advertiser address (bytes 2-7, reversed)
|
||||||
|
# BLE addresses are transmitted LSB first
|
||||||
|
addr_bytes = raw_bytes[2:8]
|
||||||
|
address = ':'.join(f'{b:02X}' for b in reversed(addr_bytes))
|
||||||
|
|
||||||
|
# Determine address type from PDU type and TxAdd flag
|
||||||
|
tx_add = (raw_bytes[0] >> 6) & 0x01
|
||||||
|
address_type = ADDRESS_TYPE_RANDOM if tx_add else ADDRESS_TYPE_PUBLIC
|
||||||
|
|
||||||
|
# Parse advertising data payload (after MAC address)
|
||||||
|
adv_data = raw_bytes[8:2 + length] if length > 6 else b''
|
||||||
|
|
||||||
|
# Parse advertising data structures
|
||||||
|
name = None
|
||||||
|
manufacturer_id = None
|
||||||
|
manufacturer_data = None
|
||||||
|
service_uuids = []
|
||||||
|
service_data = {}
|
||||||
|
tx_power = None
|
||||||
|
|
||||||
|
# Parse AD structures: each is [length][type][data...]
|
||||||
|
i = 0
|
||||||
|
while i < len(adv_data):
|
||||||
|
if i >= len(adv_data):
|
||||||
|
break
|
||||||
|
ad_len = adv_data[i]
|
||||||
|
if ad_len == 0 or i + 1 + ad_len > len(adv_data):
|
||||||
|
break
|
||||||
|
|
||||||
|
ad_type = adv_data[i + 1]
|
||||||
|
ad_payload = adv_data[i + 2:i + 1 + ad_len]
|
||||||
|
|
||||||
|
# 0x01 = Flags
|
||||||
|
# 0x02/0x03 = Incomplete/Complete list of 16-bit UUIDs
|
||||||
|
if ad_type in (0x02, 0x03) and len(ad_payload) >= 2:
|
||||||
|
for j in range(0, len(ad_payload), 2):
|
||||||
|
if j + 2 <= len(ad_payload):
|
||||||
|
uuid16 = int.from_bytes(ad_payload[j:j + 2], 'little')
|
||||||
|
service_uuids.append(f'{uuid16:04X}')
|
||||||
|
|
||||||
|
# 0x06/0x07 = Incomplete/Complete list of 128-bit UUIDs
|
||||||
|
elif ad_type in (0x06, 0x07) and len(ad_payload) >= 16:
|
||||||
|
for j in range(0, len(ad_payload), 16):
|
||||||
|
if j + 16 <= len(ad_payload):
|
||||||
|
uuid_bytes = ad_payload[j:j + 16]
|
||||||
|
uuid128 = '-'.join([
|
||||||
|
uuid_bytes[15:11:-1].hex(),
|
||||||
|
uuid_bytes[11:9:-1].hex(),
|
||||||
|
uuid_bytes[9:7:-1].hex(),
|
||||||
|
uuid_bytes[7:5:-1].hex(),
|
||||||
|
uuid_bytes[5::-1].hex(),
|
||||||
|
])
|
||||||
|
service_uuids.append(uuid128.upper())
|
||||||
|
|
||||||
|
# 0x08/0x09 = Shortened/Complete Local Name
|
||||||
|
elif ad_type in (0x08, 0x09):
|
||||||
|
try:
|
||||||
|
name = ad_payload.decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 0x0A = TX Power Level
|
||||||
|
elif ad_type == 0x0A and len(ad_payload) >= 1:
|
||||||
|
# Signed 8-bit value
|
||||||
|
tx_power = ad_payload[0] if ad_payload[0] < 128 else ad_payload[0] - 256
|
||||||
|
|
||||||
|
# 0xFF = Manufacturer Specific Data
|
||||||
|
elif ad_type == 0xFF and len(ad_payload) >= 2:
|
||||||
|
manufacturer_id = int.from_bytes(ad_payload[0:2], 'little')
|
||||||
|
manufacturer_data = bytes(ad_payload[2:])
|
||||||
|
|
||||||
|
# 0x16 = Service Data (16-bit UUID)
|
||||||
|
elif ad_type == 0x16 and len(ad_payload) >= 2:
|
||||||
|
svc_uuid = f'{int.from_bytes(ad_payload[0:2], "little"):04X}'
|
||||||
|
service_data[svc_uuid] = bytes(ad_payload[2:])
|
||||||
|
|
||||||
|
# 0x20 = Service Data (32-bit UUID)
|
||||||
|
elif ad_type == 0x20 and len(ad_payload) >= 4:
|
||||||
|
svc_uuid = f'{int.from_bytes(ad_payload[0:4], "little"):08X}'
|
||||||
|
service_data[svc_uuid] = bytes(ad_payload[4:])
|
||||||
|
|
||||||
|
# 0x21 = Service Data (128-bit UUID)
|
||||||
|
elif ad_type == 0x21 and len(ad_payload) >= 16:
|
||||||
|
uuid_bytes = ad_payload[0:16]
|
||||||
|
svc_uuid = '-'.join([
|
||||||
|
uuid_bytes[15:11:-1].hex(),
|
||||||
|
uuid_bytes[11:9:-1].hex(),
|
||||||
|
uuid_bytes[9:7:-1].hex(),
|
||||||
|
uuid_bytes[7:5:-1].hex(),
|
||||||
|
uuid_bytes[5::-1].hex(),
|
||||||
|
]).upper()
|
||||||
|
service_data[svc_uuid] = bytes(ad_payload[16:])
|
||||||
|
|
||||||
|
i += 1 + ad_len
|
||||||
|
|
||||||
|
# Determine if connectable from PDU type
|
||||||
|
# ADV_IND (0x00) and ADV_DIRECT_IND (0x01) are connectable
|
||||||
|
is_connectable = pdu_type in (0x00, 0x01)
|
||||||
|
|
||||||
|
return BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=address,
|
||||||
|
address_type=address_type,
|
||||||
|
rssi=None, # Ubertooth doesn't provide RSSI in standard mode
|
||||||
|
tx_power=tx_power,
|
||||||
|
name=name,
|
||||||
|
manufacturer_id=manufacturer_id,
|
||||||
|
manufacturer_data=manufacturer_data,
|
||||||
|
service_uuids=service_uuids,
|
||||||
|
service_data=service_data,
|
||||||
|
is_connectable=is_connectable,
|
||||||
|
)
|
||||||
@@ -385,6 +385,51 @@ def init_db() -> None:
|
|||||||
ON dsc_alerts(source_mmsi, received_at)
|
ON dsc_alerts(source_mmsi, received_at)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Remote Agent Tables (for distributed/controller mode)
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# Remote agents registry
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
api_key TEXT,
|
||||||
|
capabilities TEXT,
|
||||||
|
interfaces TEXT,
|
||||||
|
gps_coords TEXT,
|
||||||
|
last_seen TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Push payloads received from remote agents
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS push_payloads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id INTEGER NOT NULL,
|
||||||
|
scan_type TEXT NOT NULL,
|
||||||
|
interface TEXT,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Indexes for agent tables
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agents_name
|
||||||
|
ON agents(name)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_payloads_agent
|
||||||
|
ON push_payloads(agent_id, received_at)
|
||||||
|
''')
|
||||||
|
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|
||||||
@@ -1677,3 +1722,236 @@ def cleanup_old_dsc_alerts(max_age_days: int = 30) -> int:
|
|||||||
AND received_at < datetime('now', ?)
|
AND received_at < datetime('now', ?)
|
||||||
''', (f'-{max_age_days} days',))
|
''', (f'-{max_age_days} days',))
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Remote Agent Functions (for distributed/controller mode)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def create_agent(
|
||||||
|
name: str,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
capabilities: dict | None = None,
|
||||||
|
interfaces: dict | None = None,
|
||||||
|
gps_coords: dict | None = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Create a new remote agent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ID of the created agent
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
INSERT INTO agents
|
||||||
|
(name, base_url, api_key, description, capabilities, interfaces, gps_coords)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
name,
|
||||||
|
base_url.rstrip('/'),
|
||||||
|
api_key,
|
||||||
|
description,
|
||||||
|
json.dumps(capabilities) if capabilities else None,
|
||||||
|
json.dumps(interfaces) if interfaces else None,
|
||||||
|
json.dumps(gps_coords) if gps_coords else None
|
||||||
|
))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent(agent_id: int) -> dict | None:
|
||||||
|
"""Get an agent by ID."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('SELECT * FROM agents WHERE id = ?', (agent_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _row_to_agent(row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_by_name(name: str) -> dict | None:
|
||||||
|
"""Get an agent by name."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('SELECT * FROM agents WHERE name = ?', (name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _row_to_agent(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_agent(row) -> dict:
|
||||||
|
"""Convert database row to agent dict."""
|
||||||
|
return {
|
||||||
|
'id': row['id'],
|
||||||
|
'name': row['name'],
|
||||||
|
'base_url': row['base_url'],
|
||||||
|
'description': row['description'],
|
||||||
|
'api_key': row['api_key'],
|
||||||
|
'capabilities': json.loads(row['capabilities']) if row['capabilities'] else None,
|
||||||
|
'interfaces': json.loads(row['interfaces']) if row['interfaces'] else None,
|
||||||
|
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
|
||||||
|
'last_seen': row['last_seen'],
|
||||||
|
'created_at': row['created_at'],
|
||||||
|
'is_active': bool(row['is_active'])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_agents(active_only: bool = True) -> list[dict]:
|
||||||
|
"""Get all agents."""
|
||||||
|
with get_db() as conn:
|
||||||
|
if active_only:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'SELECT * FROM agents WHERE is_active = 1 ORDER BY name'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute('SELECT * FROM agents ORDER BY name')
|
||||||
|
return [_row_to_agent(row) for row in cursor]
|
||||||
|
|
||||||
|
|
||||||
|
def update_agent(
|
||||||
|
agent_id: int,
|
||||||
|
base_url: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
capabilities: dict | None = None,
|
||||||
|
interfaces: dict | None = None,
|
||||||
|
gps_coords: dict | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
update_last_seen: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Update an agent's fields."""
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if base_url is not None:
|
||||||
|
updates.append('base_url = ?')
|
||||||
|
params.append(base_url.rstrip('/'))
|
||||||
|
if description is not None:
|
||||||
|
updates.append('description = ?')
|
||||||
|
params.append(description)
|
||||||
|
if api_key is not None:
|
||||||
|
updates.append('api_key = ?')
|
||||||
|
params.append(api_key)
|
||||||
|
if capabilities is not None:
|
||||||
|
updates.append('capabilities = ?')
|
||||||
|
params.append(json.dumps(capabilities))
|
||||||
|
if interfaces is not None:
|
||||||
|
updates.append('interfaces = ?')
|
||||||
|
params.append(json.dumps(interfaces))
|
||||||
|
if gps_coords is not None:
|
||||||
|
updates.append('gps_coords = ?')
|
||||||
|
params.append(json.dumps(gps_coords))
|
||||||
|
if is_active is not None:
|
||||||
|
updates.append('is_active = ?')
|
||||||
|
params.append(1 if is_active else 0)
|
||||||
|
if update_last_seen:
|
||||||
|
updates.append('last_seen = CURRENT_TIMESTAMP')
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
params.append(agent_id)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
f'UPDATE agents SET {", ".join(updates)} WHERE id = ?',
|
||||||
|
params
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_agent(agent_id: int) -> bool:
|
||||||
|
"""Delete an agent and its push payloads."""
|
||||||
|
with get_db() as conn:
|
||||||
|
# Delete push payloads first (foreign key)
|
||||||
|
conn.execute('DELETE FROM push_payloads WHERE agent_id = ?', (agent_id,))
|
||||||
|
cursor = conn.execute('DELETE FROM agents WHERE id = ?', (agent_id,))
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def store_push_payload(
|
||||||
|
agent_id: int,
|
||||||
|
scan_type: str,
|
||||||
|
payload: dict,
|
||||||
|
interface: str | None = None,
|
||||||
|
received_at: str | None = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Store a push payload from a remote agent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ID of the created payload record
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
if received_at:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
INSERT INTO push_payloads (agent_id, scan_type, interface, payload, received_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
''', (agent_id, scan_type, interface, json.dumps(payload), received_at))
|
||||||
|
else:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
INSERT INTO push_payloads (agent_id, scan_type, interface, payload)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
''', (agent_id, scan_type, interface, json.dumps(payload)))
|
||||||
|
|
||||||
|
# Update agent last_seen
|
||||||
|
conn.execute(
|
||||||
|
'UPDATE agents SET last_seen = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
(agent_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_payloads(
|
||||||
|
agent_id: int | None = None,
|
||||||
|
scan_type: str | None = None,
|
||||||
|
limit: int = 100
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get recent push payloads, optionally filtered."""
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if agent_id is not None:
|
||||||
|
conditions.append('p.agent_id = ?')
|
||||||
|
params.append(agent_id)
|
||||||
|
if scan_type is not None:
|
||||||
|
conditions.append('p.scan_type = ?')
|
||||||
|
params.append(scan_type)
|
||||||
|
|
||||||
|
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(f'''
|
||||||
|
SELECT p.*, a.name as agent_name
|
||||||
|
FROM push_payloads p
|
||||||
|
JOIN agents a ON p.agent_id = a.id
|
||||||
|
{where_clause}
|
||||||
|
ORDER BY p.received_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
''', params)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in cursor:
|
||||||
|
results.append({
|
||||||
|
'id': row['id'],
|
||||||
|
'agent_id': row['agent_id'],
|
||||||
|
'agent_name': row['agent_name'],
|
||||||
|
'scan_type': row['scan_type'],
|
||||||
|
'interface': row['interface'],
|
||||||
|
'payload': json.loads(row['payload']),
|
||||||
|
'received_at': row['received_at']
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_payloads(max_age_hours: int = 24) -> int:
|
||||||
|
"""Remove old push payloads."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
DELETE FROM push_payloads
|
||||||
|
WHERE received_at < datetime('now', ?)
|
||||||
|
''', (f'-{max_age_hours} hours',))
|
||||||
|
return cursor.rowcount
|
||||||
|
|||||||
@@ -0,0 +1,799 @@
|
|||||||
|
"""Meshtastic device management and message handling.
|
||||||
|
|
||||||
|
This module provides integration with Meshtastic mesh networking devices,
|
||||||
|
allowing INTERCEPT to receive and decode messages from LoRa mesh networks.
|
||||||
|
|
||||||
|
Requires a physical Meshtastic device connected via USB/Serial.
|
||||||
|
Install SDK with: pip install meshtastic
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.meshtastic')
|
||||||
|
|
||||||
|
# Meshtastic SDK import (optional dependency)
|
||||||
|
try:
|
||||||
|
import meshtastic
|
||||||
|
import meshtastic.serial_interface
|
||||||
|
from meshtastic import BROADCAST_ADDR
|
||||||
|
from pubsub import pub
|
||||||
|
HAS_MESHTASTIC = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_MESHTASTIC = False
|
||||||
|
BROADCAST_ADDR = 0xFFFFFFFF # Fallback if SDK not installed
|
||||||
|
logger.warning("Meshtastic SDK not installed. Install with: pip install meshtastic")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MeshtasticMessage:
|
||||||
|
"""Decoded Meshtastic message."""
|
||||||
|
from_id: str
|
||||||
|
to_id: str
|
||||||
|
message: str | None
|
||||||
|
portnum: str
|
||||||
|
channel: int
|
||||||
|
rssi: int | None
|
||||||
|
snr: float | None
|
||||||
|
hop_limit: int | None
|
||||||
|
timestamp: datetime
|
||||||
|
from_name: str | None = None
|
||||||
|
to_name: str | None = None
|
||||||
|
raw_packet: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
'type': 'meshtastic',
|
||||||
|
'from': self.from_id,
|
||||||
|
'from_name': self.from_name,
|
||||||
|
'to': self.to_id,
|
||||||
|
'to_name': self.to_name,
|
||||||
|
'message': self.message,
|
||||||
|
'text': self.message, # Alias for frontend compatibility
|
||||||
|
'portnum': self.portnum,
|
||||||
|
'channel': self.channel,
|
||||||
|
'rssi': self.rssi,
|
||||||
|
'snr': self.snr,
|
||||||
|
'hop_limit': self.hop_limit,
|
||||||
|
'timestamp': self.timestamp.timestamp(), # Unix seconds for frontend
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChannelConfig:
|
||||||
|
"""Meshtastic channel configuration."""
|
||||||
|
index: int
|
||||||
|
name: str
|
||||||
|
psk: bytes
|
||||||
|
role: int # 0=DISABLED, 1=PRIMARY, 2=SECONDARY
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dict for API response (hides raw PSK)."""
|
||||||
|
role_names = ['DISABLED', 'PRIMARY', 'SECONDARY']
|
||||||
|
# Default key is 1 byte (0x01) or the well-known AQ== base64
|
||||||
|
is_default = self.psk in (b'\x01', b'')
|
||||||
|
return {
|
||||||
|
'index': self.index,
|
||||||
|
'name': self.name,
|
||||||
|
'role': role_names[self.role] if self.role < len(role_names) else 'UNKNOWN',
|
||||||
|
'encrypted': len(self.psk) > 1,
|
||||||
|
'key_type': self._get_key_type(),
|
||||||
|
'is_default_key': is_default,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_key_type(self) -> str:
|
||||||
|
"""Determine encryption type from key length."""
|
||||||
|
if len(self.psk) == 0:
|
||||||
|
return 'none'
|
||||||
|
elif len(self.psk) == 1:
|
||||||
|
return 'default'
|
||||||
|
elif len(self.psk) == 16:
|
||||||
|
return 'AES-128'
|
||||||
|
elif len(self.psk) == 32:
|
||||||
|
return 'AES-256'
|
||||||
|
else:
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MeshNode:
|
||||||
|
"""Tracked Meshtastic node with position and metadata."""
|
||||||
|
num: int
|
||||||
|
user_id: str
|
||||||
|
long_name: str
|
||||||
|
short_name: str
|
||||||
|
hw_model: str
|
||||||
|
latitude: float | None = None
|
||||||
|
longitude: float | None = None
|
||||||
|
altitude: int | None = None
|
||||||
|
battery_level: int | None = None
|
||||||
|
snr: float | None = None
|
||||||
|
last_heard: datetime | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
'num': self.num,
|
||||||
|
'id': self.user_id or f"!{self.num:08x}",
|
||||||
|
'long_name': self.long_name,
|
||||||
|
'short_name': self.short_name,
|
||||||
|
'hw_model': self.hw_model,
|
||||||
|
'latitude': self.latitude,
|
||||||
|
'longitude': self.longitude,
|
||||||
|
'altitude': self.altitude,
|
||||||
|
'battery_level': self.battery_level,
|
||||||
|
'snr': self.snr,
|
||||||
|
'last_heard': self.last_heard.isoformat() if self.last_heard else None,
|
||||||
|
'has_position': self.latitude is not None and self.longitude is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NodeInfo:
|
||||||
|
"""Meshtastic node information."""
|
||||||
|
num: int
|
||||||
|
user_id: str
|
||||||
|
long_name: str
|
||||||
|
short_name: str
|
||||||
|
hw_model: str
|
||||||
|
latitude: float | None
|
||||||
|
longitude: float | None
|
||||||
|
altitude: int | None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
'num': self.num,
|
||||||
|
'user_id': self.user_id,
|
||||||
|
'long_name': self.long_name,
|
||||||
|
'short_name': self.short_name,
|
||||||
|
'hw_model': self.hw_model,
|
||||||
|
'position': {
|
||||||
|
'latitude': self.latitude,
|
||||||
|
'longitude': self.longitude,
|
||||||
|
'altitude': self.altitude,
|
||||||
|
} if self.latitude is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MeshtasticClient:
|
||||||
|
"""Client for connecting to Meshtastic devices."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._interface = None
|
||||||
|
self._running = False
|
||||||
|
self._callback: Callable[[MeshtasticMessage], None] | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._nodes: dict[int, MeshNode] = {} # num -> MeshNode
|
||||||
|
self._device_path: str | None = None
|
||||||
|
self._error: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_path(self) -> str | None:
|
||||||
|
return self._device_path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error(self) -> str | None:
|
||||||
|
return self._error
|
||||||
|
|
||||||
|
def set_callback(self, callback: Callable[[MeshtasticMessage], None]) -> None:
|
||||||
|
"""Set callback for received messages."""
|
||||||
|
self._callback = callback
|
||||||
|
|
||||||
|
def connect(self, device: str | None = None) -> bool:
|
||||||
|
"""
|
||||||
|
Connect to a Meshtastic device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: Serial port path (e.g., /dev/ttyUSB0, /dev/ttyACM0).
|
||||||
|
If None, auto-discovers first available device.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if connected successfully.
|
||||||
|
"""
|
||||||
|
if not HAS_MESHTASTIC:
|
||||||
|
self._error = "Meshtastic SDK not installed. Install with: pip install meshtastic"
|
||||||
|
return False
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self._running:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Subscribe to message events before connecting
|
||||||
|
pub.subscribe(self._on_receive, "meshtastic.receive")
|
||||||
|
pub.subscribe(self._on_connection, "meshtastic.connection.established")
|
||||||
|
pub.subscribe(self._on_disconnect, "meshtastic.connection.lost")
|
||||||
|
|
||||||
|
# Connect to device
|
||||||
|
if device:
|
||||||
|
self._interface = meshtastic.serial_interface.SerialInterface(device)
|
||||||
|
self._device_path = device
|
||||||
|
else:
|
||||||
|
# Auto-discover
|
||||||
|
self._interface = meshtastic.serial_interface.SerialInterface()
|
||||||
|
self._device_path = "auto"
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._error = None
|
||||||
|
logger.info(f"Connected to Meshtastic device: {self._device_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._error = str(e)
|
||||||
|
logger.error(f"Failed to connect to Meshtastic: {e}")
|
||||||
|
self._cleanup_subscriptions()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
"""Disconnect from the Meshtastic device."""
|
||||||
|
with self._lock:
|
||||||
|
if self._interface:
|
||||||
|
try:
|
||||||
|
self._interface.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing Meshtastic interface: {e}")
|
||||||
|
self._interface = None
|
||||||
|
|
||||||
|
self._cleanup_subscriptions()
|
||||||
|
self._running = False
|
||||||
|
self._device_path = None
|
||||||
|
logger.info("Disconnected from Meshtastic device")
|
||||||
|
|
||||||
|
def _cleanup_subscriptions(self) -> None:
|
||||||
|
"""Unsubscribe from pubsub topics."""
|
||||||
|
if HAS_MESHTASTIC:
|
||||||
|
try:
|
||||||
|
pub.unsubscribe(self._on_receive, "meshtastic.receive")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
pub.unsubscribe(self._on_connection, "meshtastic.connection.established")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
pub.unsubscribe(self._on_disconnect, "meshtastic.connection.lost")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_connection(self, interface, topic=None) -> None:
|
||||||
|
"""Handle connection established event."""
|
||||||
|
logger.info("Meshtastic connection established")
|
||||||
|
# Sync nodes from device's nodeDB so names are available for messages
|
||||||
|
self._sync_nodes_from_interface()
|
||||||
|
# Try to set device time from host computer
|
||||||
|
self._sync_device_time()
|
||||||
|
|
||||||
|
def _on_disconnect(self, interface, topic=None) -> None:
|
||||||
|
"""Handle connection lost event."""
|
||||||
|
logger.warning("Meshtastic connection lost")
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def _sync_device_time(self) -> None:
|
||||||
|
"""Sync device time from host computer."""
|
||||||
|
if not self._interface:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Try to set the device's time using the SDK
|
||||||
|
import time
|
||||||
|
current_time = int(time.time())
|
||||||
|
if hasattr(self._interface, 'localNode') and self._interface.localNode:
|
||||||
|
local_node = self._interface.localNode
|
||||||
|
if hasattr(local_node, 'setTime'):
|
||||||
|
local_node.setTime(current_time)
|
||||||
|
logger.info(f"Set device time to {current_time}")
|
||||||
|
elif hasattr(self._interface, 'sendAdmin'):
|
||||||
|
# Alternative: send admin message with time
|
||||||
|
logger.debug("setTime not available, device time not synced")
|
||||||
|
else:
|
||||||
|
logger.debug("localNode not available, device time not synced")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to sync device time: {e}")
|
||||||
|
|
||||||
|
def _on_receive(self, packet: dict, interface) -> None:
|
||||||
|
"""Handle received packet from Meshtastic device."""
|
||||||
|
try:
|
||||||
|
decoded = packet.get('decoded', {})
|
||||||
|
from_num = packet.get('from', 0)
|
||||||
|
to_num = packet.get('to', 0)
|
||||||
|
portnum = decoded.get('portnum', 'UNKNOWN')
|
||||||
|
|
||||||
|
# Track node from packet (always, even for filtered messages)
|
||||||
|
self._track_node_from_packet(packet, decoded, portnum)
|
||||||
|
|
||||||
|
# Skip callback if none set
|
||||||
|
if not self._callback:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Filter out internal protocol messages that aren't useful to users
|
||||||
|
ignored_portnums = {
|
||||||
|
'ROUTING_APP', # Mesh routing/acknowledgments
|
||||||
|
'ADMIN_APP', # Admin commands
|
||||||
|
'REPLY_APP', # Internal replies
|
||||||
|
'STORE_FORWARD_APP', # Store and forward protocol
|
||||||
|
'RANGE_TEST_APP', # Range testing
|
||||||
|
'PAXCOUNTER_APP', # People counter
|
||||||
|
'REMOTE_HARDWARE_APP', # Remote hardware control
|
||||||
|
'SIMULATOR_APP', # Simulator
|
||||||
|
'MAP_REPORT_APP', # Map reporting
|
||||||
|
'TELEMETRY_APP', # Device telemetry (battery, etc.) - too noisy
|
||||||
|
'POSITION_APP', # Position updates - used for map, not messages
|
||||||
|
'NODEINFO_APP', # Node info - used for tracking, not messages
|
||||||
|
}
|
||||||
|
if portnum in ignored_portnums:
|
||||||
|
logger.debug(f"Ignoring {portnum} message from {from_num}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract text message if present
|
||||||
|
message = None
|
||||||
|
if portnum == 'TEXT_MESSAGE_APP':
|
||||||
|
message = decoded.get('text')
|
||||||
|
elif portnum in ('WAYPOINT_APP', 'TRACEROUTE_APP'):
|
||||||
|
# Show these as informational messages
|
||||||
|
message = f"[{portnum}]"
|
||||||
|
elif 'payload' in decoded:
|
||||||
|
# For other message types, include payload info
|
||||||
|
message = f"[{portnum}]"
|
||||||
|
|
||||||
|
# Look up node names - try cache first, then SDK's nodeDB
|
||||||
|
from_name = self._lookup_node_name(from_num)
|
||||||
|
to_name = self._lookup_node_name(to_num) if to_num != BROADCAST_ADDR else None
|
||||||
|
|
||||||
|
msg = MeshtasticMessage(
|
||||||
|
from_id=self._format_node_id(from_num),
|
||||||
|
to_id=self._format_node_id(to_num),
|
||||||
|
message=message,
|
||||||
|
portnum=portnum,
|
||||||
|
channel=packet.get('channel', 0),
|
||||||
|
rssi=packet.get('rxRssi'),
|
||||||
|
snr=packet.get('rxSnr'),
|
||||||
|
hop_limit=packet.get('hopLimit'),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
from_name=from_name,
|
||||||
|
to_name=to_name,
|
||||||
|
raw_packet=packet,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._callback(msg)
|
||||||
|
logger.debug(f"Received: {msg.from_id} -> {msg.to_id}: {msg.portnum}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing Meshtastic packet: {e}")
|
||||||
|
|
||||||
|
def _track_node_from_packet(self, packet: dict, decoded: dict, portnum: str) -> None:
|
||||||
|
"""Update node tracking from received packet."""
|
||||||
|
from_num = packet.get('from', 0)
|
||||||
|
if from_num == 0 or from_num == 0xFFFFFFFF:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Get or create node entry
|
||||||
|
if from_num not in self._nodes:
|
||||||
|
self._nodes[from_num] = MeshNode(
|
||||||
|
num=from_num,
|
||||||
|
user_id=f"!{from_num:08x}",
|
||||||
|
long_name='',
|
||||||
|
short_name='',
|
||||||
|
hw_model='UNKNOWN',
|
||||||
|
)
|
||||||
|
|
||||||
|
node = self._nodes[from_num]
|
||||||
|
node.last_heard = now
|
||||||
|
node.snr = packet.get('rxSnr', node.snr)
|
||||||
|
|
||||||
|
# Parse NODEINFO_APP for user details
|
||||||
|
if portnum == 'NODEINFO_APP':
|
||||||
|
user = decoded.get('user', {})
|
||||||
|
if user:
|
||||||
|
node.long_name = user.get('longName', node.long_name)
|
||||||
|
node.short_name = user.get('shortName', node.short_name)
|
||||||
|
node.hw_model = user.get('hwModel', node.hw_model)
|
||||||
|
if user.get('id'):
|
||||||
|
node.user_id = user.get('id')
|
||||||
|
|
||||||
|
# Parse POSITION_APP for location
|
||||||
|
elif portnum == 'POSITION_APP':
|
||||||
|
position = decoded.get('position', {})
|
||||||
|
if position:
|
||||||
|
lat = position.get('latitude') or position.get('latitudeI')
|
||||||
|
lon = position.get('longitude') or position.get('longitudeI')
|
||||||
|
|
||||||
|
# Handle integer format (latitudeI/longitudeI are in 1e-7 degrees)
|
||||||
|
if isinstance(lat, int) and abs(lat) > 1000:
|
||||||
|
lat = lat / 1e7
|
||||||
|
if isinstance(lon, int) and abs(lon) > 1000:
|
||||||
|
lon = lon / 1e7
|
||||||
|
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
node.latitude = lat
|
||||||
|
node.longitude = lon
|
||||||
|
node.altitude = position.get('altitude', node.altitude)
|
||||||
|
|
||||||
|
# Parse TELEMETRY_APP for battery
|
||||||
|
elif portnum == 'TELEMETRY_APP':
|
||||||
|
telemetry = decoded.get('telemetry', {})
|
||||||
|
device_metrics = telemetry.get('deviceMetrics', {})
|
||||||
|
if device_metrics:
|
||||||
|
battery = device_metrics.get('batteryLevel')
|
||||||
|
if battery is not None:
|
||||||
|
node.battery_level = battery
|
||||||
|
|
||||||
|
def _lookup_node_name(self, node_num: int) -> str | None:
|
||||||
|
"""Look up a node's name by its number."""
|
||||||
|
if node_num == 0 or node_num == BROADCAST_ADDR:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try our cache first
|
||||||
|
if node_num in self._nodes:
|
||||||
|
node = self._nodes[node_num]
|
||||||
|
name = node.short_name or node.long_name
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Try SDK's nodeDB with various key formats
|
||||||
|
if self._interface and hasattr(self._interface, 'nodes') and self._interface.nodes:
|
||||||
|
nodes = self._interface.nodes
|
||||||
|
|
||||||
|
# Try direct lookup with different key formats
|
||||||
|
for key in [node_num, f"!{node_num:08x}", f"!{node_num:x}", str(node_num)]:
|
||||||
|
if key in nodes:
|
||||||
|
user = nodes[key].get('user', {})
|
||||||
|
name = user.get('shortName') or user.get('longName')
|
||||||
|
if name:
|
||||||
|
logger.debug(f"Found name '{name}' for node {node_num} with key {key}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Search through all nodes by num field
|
||||||
|
for key, node_data in nodes.items():
|
||||||
|
if node_data.get('num') == node_num:
|
||||||
|
user = node_data.get('user', {})
|
||||||
|
name = user.get('shortName') or user.get('longName')
|
||||||
|
if name:
|
||||||
|
logger.debug(f"Found name '{name}' for node {node_num} by search")
|
||||||
|
return name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_node_id(node_num: int) -> str:
|
||||||
|
"""Format node number as hex string."""
|
||||||
|
if node_num == 0xFFFFFFFF:
|
||||||
|
return "^all"
|
||||||
|
return f"!{node_num:08x}"
|
||||||
|
|
||||||
|
def get_node_info(self) -> NodeInfo | None:
|
||||||
|
"""Get local node information."""
|
||||||
|
if not self._interface:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
node = self._interface.getMyNodeInfo()
|
||||||
|
user = node.get('user', {})
|
||||||
|
position = node.get('position', {})
|
||||||
|
|
||||||
|
return NodeInfo(
|
||||||
|
num=node.get('num', 0),
|
||||||
|
user_id=user.get('id', ''),
|
||||||
|
long_name=user.get('longName', ''),
|
||||||
|
short_name=user.get('shortName', ''),
|
||||||
|
hw_model=user.get('hwModel', 'UNKNOWN'),
|
||||||
|
latitude=position.get('latitude'),
|
||||||
|
longitude=position.get('longitude'),
|
||||||
|
altitude=position.get('altitude'),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting node info: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_nodes(self) -> list[MeshNode]:
|
||||||
|
"""Get all tracked nodes."""
|
||||||
|
# Also pull nodes from the SDK's nodeDB if available
|
||||||
|
self._sync_nodes_from_interface()
|
||||||
|
return list(self._nodes.values())
|
||||||
|
|
||||||
|
def _sync_nodes_from_interface(self) -> None:
|
||||||
|
"""Sync nodes from the Meshtastic SDK's nodeDB."""
|
||||||
|
if not self._interface:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
nodes = self._interface.nodes
|
||||||
|
if not nodes:
|
||||||
|
return
|
||||||
|
|
||||||
|
for node_id, node_data in nodes.items():
|
||||||
|
# Skip if it's a string key like '!abcd1234'
|
||||||
|
if isinstance(node_id, str):
|
||||||
|
try:
|
||||||
|
num = int(node_id[1:], 16) if node_id.startswith('!') else int(node_id)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
num = node_id
|
||||||
|
|
||||||
|
user = node_data.get('user', {})
|
||||||
|
position = node_data.get('position', {})
|
||||||
|
|
||||||
|
# Get or create node
|
||||||
|
if num not in self._nodes:
|
||||||
|
self._nodes[num] = MeshNode(
|
||||||
|
num=num,
|
||||||
|
user_id=user.get('id', f"!{num:08x}"),
|
||||||
|
long_name=user.get('longName', ''),
|
||||||
|
short_name=user.get('shortName', ''),
|
||||||
|
hw_model=user.get('hwModel', 'UNKNOWN'),
|
||||||
|
)
|
||||||
|
|
||||||
|
node = self._nodes[num]
|
||||||
|
|
||||||
|
# Update from SDK data
|
||||||
|
if user:
|
||||||
|
node.long_name = user.get('longName', node.long_name) or node.long_name
|
||||||
|
node.short_name = user.get('shortName', node.short_name) or node.short_name
|
||||||
|
node.hw_model = user.get('hwModel', node.hw_model) or node.hw_model
|
||||||
|
if user.get('id'):
|
||||||
|
node.user_id = user.get('id')
|
||||||
|
|
||||||
|
if position:
|
||||||
|
lat = position.get('latitude')
|
||||||
|
lon = position.get('longitude')
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
node.latitude = lat
|
||||||
|
node.longitude = lon
|
||||||
|
node.altitude = position.get('altitude', node.altitude)
|
||||||
|
|
||||||
|
# Update last heard from SDK
|
||||||
|
last_heard = node_data.get('lastHeard')
|
||||||
|
if last_heard:
|
||||||
|
node.last_heard = datetime.fromtimestamp(last_heard, tz=timezone.utc)
|
||||||
|
|
||||||
|
# Update SNR
|
||||||
|
node.snr = node_data.get('snr', node.snr)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error syncing nodes from interface: {e}")
|
||||||
|
|
||||||
|
def get_channels(self) -> list[ChannelConfig]:
|
||||||
|
"""Get all configured channels."""
|
||||||
|
if not self._interface:
|
||||||
|
return []
|
||||||
|
|
||||||
|
channels = []
|
||||||
|
try:
|
||||||
|
for i, ch in enumerate(self._interface.localNode.channels):
|
||||||
|
if ch.role != 0: # 0 = DISABLED
|
||||||
|
channels.append(ChannelConfig(
|
||||||
|
index=i,
|
||||||
|
name=ch.settings.name or f"Channel {i}",
|
||||||
|
psk=bytes(ch.settings.psk) if ch.settings.psk else b'',
|
||||||
|
role=ch.role,
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting channels: {e}")
|
||||||
|
return channels
|
||||||
|
|
||||||
|
def send_text(self, text: str, channel: int = 0,
|
||||||
|
destination: str | int | None = None) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Send a text message to the mesh network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Message text (max 237 characters)
|
||||||
|
channel: Channel index to send on (0-7)
|
||||||
|
destination: Target node ID (string like "!a1b2c3d4" or int).
|
||||||
|
None or "^all" for broadcast.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, error_message)
|
||||||
|
"""
|
||||||
|
if not self._interface:
|
||||||
|
return False, "Not connected to device"
|
||||||
|
|
||||||
|
if not text or len(text) > 237:
|
||||||
|
return False, "Message must be 1-237 characters"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse destination - use broadcast address for None/^all
|
||||||
|
dest_id = BROADCAST_ADDR # Default to broadcast
|
||||||
|
|
||||||
|
if destination:
|
||||||
|
if isinstance(destination, int):
|
||||||
|
dest_id = destination
|
||||||
|
elif destination == "^all":
|
||||||
|
dest_id = BROADCAST_ADDR
|
||||||
|
elif destination.startswith('!'):
|
||||||
|
dest_id = int(destination[1:], 16)
|
||||||
|
else:
|
||||||
|
# Try parsing as integer
|
||||||
|
try:
|
||||||
|
dest_id = int(destination)
|
||||||
|
except ValueError:
|
||||||
|
return False, f"Invalid destination: {destination}"
|
||||||
|
|
||||||
|
# Send the message using sendData for more control
|
||||||
|
logger.debug(f"Calling sendData: text='{text[:30]}', dest={dest_id}, channel={channel}")
|
||||||
|
|
||||||
|
# Use sendData with TEXT_MESSAGE_APP portnum
|
||||||
|
# This gives us more control over the packet
|
||||||
|
from meshtastic import portnums_pb2
|
||||||
|
|
||||||
|
self._interface.sendData(
|
||||||
|
text.encode('utf-8'),
|
||||||
|
destinationId=dest_id,
|
||||||
|
portNum=portnums_pb2.PortNum.TEXT_MESSAGE_APP,
|
||||||
|
channelIndex=channel,
|
||||||
|
)
|
||||||
|
logger.debug("sendData completed")
|
||||||
|
|
||||||
|
dest_str = "^all" if dest_id == BROADCAST_ADDR else f"!{dest_id:08x}"
|
||||||
|
logger.info(f"Sent message to {dest_str} on channel {channel}: {text[:50]}...")
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending message: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def set_channel(self, index: int, name: str | None = None,
|
||||||
|
psk: str | None = None) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Configure a channel with encryption key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Channel index (0-7)
|
||||||
|
name: Channel name (optional)
|
||||||
|
psk: Pre-shared key in one of these formats:
|
||||||
|
- "none" - disable encryption
|
||||||
|
- "default" - use default (public) key
|
||||||
|
- "random" - generate new AES-256 key
|
||||||
|
- "base64:..." - base64-encoded key (16 or 32 bytes)
|
||||||
|
- "0x..." - hex-encoded key (16 or 32 bytes)
|
||||||
|
- "simple:passphrase" - derive key from passphrase (AES-256)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
if not self._interface:
|
||||||
|
return False, "Not connected to device"
|
||||||
|
|
||||||
|
if not 0 <= index <= 7:
|
||||||
|
return False, f"Invalid channel index: {index}. Must be 0-7."
|
||||||
|
|
||||||
|
try:
|
||||||
|
ch = self._interface.localNode.channels[index]
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
ch.settings.name = name
|
||||||
|
|
||||||
|
if psk is not None:
|
||||||
|
psk_bytes = self._parse_psk(psk)
|
||||||
|
if psk_bytes is None:
|
||||||
|
return False, f"Invalid PSK format: {psk}"
|
||||||
|
ch.settings.psk = psk_bytes
|
||||||
|
|
||||||
|
# Enable channel if it was disabled
|
||||||
|
if ch.role == 0:
|
||||||
|
ch.role = 2 # SECONDARY (1 = PRIMARY, only one allowed)
|
||||||
|
|
||||||
|
# Write config to device
|
||||||
|
self._interface.localNode.writeChannel(index)
|
||||||
|
logger.info(f"Channel {index} configured: {name or ch.settings.name}")
|
||||||
|
return True, f"Channel {index} configured successfully"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting channel: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def _parse_psk(self, psk: str) -> bytes | None:
|
||||||
|
"""
|
||||||
|
Parse PSK string into bytes.
|
||||||
|
|
||||||
|
Supported formats:
|
||||||
|
- "none" - no encryption (empty key)
|
||||||
|
- "default" - use default public key (1 byte)
|
||||||
|
- "random" - generate random 32-byte AES-256 key
|
||||||
|
- "base64:..." - base64-encoded key
|
||||||
|
- "0x..." - hex-encoded key
|
||||||
|
- "simple:passphrase" - SHA-256 hash of passphrase
|
||||||
|
"""
|
||||||
|
psk = psk.strip()
|
||||||
|
|
||||||
|
if psk.lower() == 'none':
|
||||||
|
return b''
|
||||||
|
|
||||||
|
if psk.lower() == 'default':
|
||||||
|
# Default key (1 byte = use default)
|
||||||
|
return b'\x01'
|
||||||
|
|
||||||
|
if psk.lower() == 'random':
|
||||||
|
# Generate random 32-byte key
|
||||||
|
return secrets.token_bytes(32)
|
||||||
|
|
||||||
|
if psk.startswith('base64:'):
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(psk[7:])
|
||||||
|
if len(decoded) not in (0, 1, 16, 32):
|
||||||
|
logger.warning(f"PSK length {len(decoded)} is non-standard")
|
||||||
|
return decoded
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if psk.startswith('0x'):
|
||||||
|
try:
|
||||||
|
decoded = bytes.fromhex(psk[2:])
|
||||||
|
if len(decoded) not in (0, 1, 16, 32):
|
||||||
|
logger.warning(f"PSK length {len(decoded)} is non-standard")
|
||||||
|
return decoded
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if psk.startswith('simple:'):
|
||||||
|
# Hash passphrase to create 32-byte AES-256 key
|
||||||
|
passphrase = psk[7:].encode('utf-8')
|
||||||
|
return hashlib.sha256(passphrase).digest()
|
||||||
|
|
||||||
|
# Try as raw base64 (for compatibility)
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(psk)
|
||||||
|
if len(decoded) in (0, 1, 16, 32):
|
||||||
|
return decoded
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance
|
||||||
|
_client: MeshtasticClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_meshtastic_client() -> MeshtasticClient | None:
|
||||||
|
"""Get the global Meshtastic client instance."""
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def start_meshtastic(device: str | None = None,
|
||||||
|
callback: Callable[[MeshtasticMessage], None] | None = None) -> bool:
|
||||||
|
"""
|
||||||
|
Start the Meshtastic client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: Serial port path (optional, auto-discovers if not provided)
|
||||||
|
callback: Function to call when messages are received
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if started successfully
|
||||||
|
"""
|
||||||
|
global _client
|
||||||
|
|
||||||
|
if _client and _client.is_running:
|
||||||
|
return True
|
||||||
|
|
||||||
|
_client = MeshtasticClient()
|
||||||
|
if callback:
|
||||||
|
_client.set_callback(callback)
|
||||||
|
|
||||||
|
return _client.connect(device)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_meshtastic() -> None:
|
||||||
|
"""Stop the Meshtastic client."""
|
||||||
|
global _client
|
||||||
|
if _client:
|
||||||
|
_client.disconnect()
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_meshtastic_available() -> bool:
|
||||||
|
"""Check if Meshtastic SDK is installed."""
|
||||||
|
return HAS_MESHTASTIC
|
||||||
@@ -179,8 +179,7 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
cmd = [
|
cmd = [
|
||||||
'AIS-catcher',
|
'AIS-catcher',
|
||||||
f'-d:{device.index}', # Device index (colon format required)
|
f'-d:{device.index}', # Device index (colon format required)
|
||||||
'-S', str(tcp_port), # TCP server with JSON output
|
'-S', str(tcp_port), 'JSON_FULL', 'on', # TCP server with full JSON output
|
||||||
'-o', '5', # JSON output format
|
|
||||||
'-q', # Quiet mode (less console output)
|
'-q', # Quiet mode (less console output)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -200,3 +199,4 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
def get_sdr_type(cls) -> SDRType:
|
def get_sdr_type(cls) -> SDRType:
|
||||||
"""Return SDR type."""
|
"""Return SDR type."""
|
||||||
return SDRType.RTL_SDR
|
return SDRType.RTL_SDR
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
"""
|
||||||
|
Trilateration/Multilateration utilities for estimating device locations
|
||||||
|
from multiple agent observations using RSSI signal strength.
|
||||||
|
|
||||||
|
This module enables location estimation for devices that don't transmit
|
||||||
|
their own GPS coordinates (WiFi APs, Bluetooth devices, etc.) by using
|
||||||
|
signal strength measurements from multiple agents at known positions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.trilateration')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Data Classes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentObservation:
|
||||||
|
"""A single observation of a device by an agent."""
|
||||||
|
agent_name: str
|
||||||
|
agent_lat: float
|
||||||
|
agent_lon: float
|
||||||
|
rssi: float # dBm
|
||||||
|
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
frequency_mhz: Optional[float] = None # For frequency-dependent path loss
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LocationEstimate:
|
||||||
|
"""Estimated location of a device with confidence metrics."""
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
accuracy_meters: float # Estimated accuracy radius
|
||||||
|
confidence: float # 0.0 to 1.0
|
||||||
|
num_observations: int
|
||||||
|
observations: List[AgentObservation] = field(default_factory=list)
|
||||||
|
method: str = "multilateration"
|
||||||
|
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to JSON-serializable dictionary."""
|
||||||
|
return {
|
||||||
|
'latitude': self.latitude,
|
||||||
|
'longitude': self.longitude,
|
||||||
|
'accuracy_meters': self.accuracy_meters,
|
||||||
|
'confidence': self.confidence,
|
||||||
|
'num_observations': self.num_observations,
|
||||||
|
'method': self.method,
|
||||||
|
'timestamp': self.timestamp.isoformat(),
|
||||||
|
'agents': [obs.agent_name for obs in self.observations]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Path Loss Models
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class PathLossModel:
|
||||||
|
"""
|
||||||
|
Convert RSSI to estimated distance using path loss models.
|
||||||
|
|
||||||
|
The free-space path loss (FSPL) model is:
|
||||||
|
FSPL(dB) = 20*log10(d) + 20*log10(f) - 147.55
|
||||||
|
|
||||||
|
Rearranged for distance:
|
||||||
|
d = 10^((RSSI_ref - RSSI) / (10 * n))
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- n is the path loss exponent (2 for free space, 2.5-4 for indoor)
|
||||||
|
- RSSI_ref is the RSSI at 1 meter reference distance
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Default parameters for different environments
|
||||||
|
ENVIRONMENTS = {
|
||||||
|
'free_space': {'n': 2.0, 'rssi_ref': -40},
|
||||||
|
'outdoor': {'n': 2.5, 'rssi_ref': -45},
|
||||||
|
'indoor': {'n': 3.0, 'rssi_ref': -50},
|
||||||
|
'indoor_obstructed': {'n': 4.0, 'rssi_ref': -55},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frequency-specific reference RSSI adjustments (WiFi vs Bluetooth)
|
||||||
|
FREQUENCY_ADJUSTMENTS = {
|
||||||
|
2400: 0, # 2.4 GHz WiFi/Bluetooth - baseline
|
||||||
|
5000: -3, # 5 GHz WiFi - weaker propagation
|
||||||
|
900: +5, # 900 MHz ISM - better propagation
|
||||||
|
433: +8, # 433 MHz sensors - even better
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
environment: str = 'outdoor',
|
||||||
|
path_loss_exponent: Optional[float] = None,
|
||||||
|
reference_rssi: Optional[float] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize path loss model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
environment: One of 'free_space', 'outdoor', 'indoor', 'indoor_obstructed'
|
||||||
|
path_loss_exponent: Override the environment's default n value
|
||||||
|
reference_rssi: Override the environment's default RSSI at 1m
|
||||||
|
"""
|
||||||
|
env_params = self.ENVIRONMENTS.get(environment, self.ENVIRONMENTS['outdoor'])
|
||||||
|
self.n = path_loss_exponent if path_loss_exponent is not None else env_params['n']
|
||||||
|
self.rssi_ref = reference_rssi if reference_rssi is not None else env_params['rssi_ref']
|
||||||
|
|
||||||
|
def rssi_to_distance(
|
||||||
|
self,
|
||||||
|
rssi: float,
|
||||||
|
frequency_mhz: Optional[float] = None
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Convert RSSI to estimated distance in meters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rssi: Measured RSSI in dBm
|
||||||
|
frequency_mhz: Signal frequency for adjustment (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estimated distance in meters
|
||||||
|
"""
|
||||||
|
# Apply frequency adjustment if known
|
||||||
|
adjusted_ref = self.rssi_ref
|
||||||
|
if frequency_mhz:
|
||||||
|
for freq, adj in self.FREQUENCY_ADJUSTMENTS.items():
|
||||||
|
if abs(frequency_mhz - freq) < 500:
|
||||||
|
adjusted_ref += adj
|
||||||
|
break
|
||||||
|
|
||||||
|
# Calculate distance using log-distance path loss model
|
||||||
|
# d = 10^((RSSI_ref - RSSI) / (10 * n))
|
||||||
|
try:
|
||||||
|
exponent = (adjusted_ref - rssi) / (10.0 * self.n)
|
||||||
|
distance = math.pow(10, exponent)
|
||||||
|
|
||||||
|
# Sanity bounds
|
||||||
|
distance = max(0.5, min(distance, 10000))
|
||||||
|
return distance
|
||||||
|
except (ValueError, OverflowError):
|
||||||
|
return 100.0 # Default fallback
|
||||||
|
|
||||||
|
def distance_to_rssi(
|
||||||
|
self,
|
||||||
|
distance: float,
|
||||||
|
frequency_mhz: Optional[float] = None
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Estimate RSSI at a given distance (inverse of rssi_to_distance).
|
||||||
|
Useful for testing and validation.
|
||||||
|
"""
|
||||||
|
if distance <= 0:
|
||||||
|
distance = 0.5
|
||||||
|
|
||||||
|
adjusted_ref = self.rssi_ref
|
||||||
|
if frequency_mhz:
|
||||||
|
for freq, adj in self.FREQUENCY_ADJUSTMENTS.items():
|
||||||
|
if abs(frequency_mhz - freq) < 500:
|
||||||
|
adjusted_ref += adj
|
||||||
|
break
|
||||||
|
|
||||||
|
# RSSI = RSSI_ref - 10 * n * log10(d)
|
||||||
|
rssi = adjusted_ref - (10.0 * self.n * math.log10(distance))
|
||||||
|
return rssi
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Geographic Utilities
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
"""
|
||||||
|
Calculate the great-circle distance between two points in meters.
|
||||||
|
|
||||||
|
Uses the Haversine formula for accuracy on Earth's surface.
|
||||||
|
"""
|
||||||
|
R = 6371000 # Earth's radius in meters
|
||||||
|
|
||||||
|
phi1 = math.radians(lat1)
|
||||||
|
phi2 = math.radians(lat2)
|
||||||
|
delta_phi = math.radians(lat2 - lat1)
|
||||||
|
delta_lambda = math.radians(lon2 - lon1)
|
||||||
|
|
||||||
|
a = math.sin(delta_phi / 2) ** 2 + \
|
||||||
|
math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2
|
||||||
|
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||||
|
|
||||||
|
return R * c
|
||||||
|
|
||||||
|
|
||||||
|
def meters_to_degrees(meters: float, latitude: float) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Convert meters to approximate degrees at a given latitude.
|
||||||
|
|
||||||
|
Returns (lat_degrees, lon_degrees) for the given distance.
|
||||||
|
"""
|
||||||
|
# Latitude: roughly constant at ~111km per degree
|
||||||
|
lat_deg = meters / 111000.0
|
||||||
|
|
||||||
|
# Longitude: varies with latitude
|
||||||
|
lon_deg = meters / (111000.0 * math.cos(math.radians(latitude)))
|
||||||
|
|
||||||
|
return lat_deg, lon_deg
|
||||||
|
|
||||||
|
|
||||||
|
def offset_position(lat: float, lon: float, north_m: float, east_m: float) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Offset a GPS position by meters north and east.
|
||||||
|
|
||||||
|
Returns (new_lat, new_lon).
|
||||||
|
"""
|
||||||
|
lat_offset = north_m / 111000.0
|
||||||
|
lon_offset = east_m / (111000.0 * math.cos(math.radians(lat)))
|
||||||
|
|
||||||
|
return lat + lat_offset, lon + lon_offset
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Trilateration Algorithm
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class Trilateration:
|
||||||
|
"""
|
||||||
|
Estimate device location using multilateration from multiple RSSI observations.
|
||||||
|
|
||||||
|
Multilateration works by:
|
||||||
|
1. Converting RSSI to estimated distance from each observer
|
||||||
|
2. Finding the point that minimizes the sum of squared distance errors
|
||||||
|
3. Using iterative refinement for better accuracy
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path_loss_model: Optional[PathLossModel] = None,
|
||||||
|
min_observations: int = 2,
|
||||||
|
max_iterations: int = 100,
|
||||||
|
convergence_threshold: float = 0.1 # meters
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize trilateration engine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path_loss_model: Model for RSSI to distance conversion
|
||||||
|
min_observations: Minimum number of observations required
|
||||||
|
max_iterations: Maximum iterations for refinement
|
||||||
|
convergence_threshold: Stop when movement is less than this (meters)
|
||||||
|
"""
|
||||||
|
self.path_loss = path_loss_model or PathLossModel()
|
||||||
|
self.min_observations = min_observations
|
||||||
|
self.max_iterations = max_iterations
|
||||||
|
self.convergence_threshold = convergence_threshold
|
||||||
|
|
||||||
|
def estimate_location(
|
||||||
|
self,
|
||||||
|
observations: List[AgentObservation]
|
||||||
|
) -> Optional[LocationEstimate]:
|
||||||
|
"""
|
||||||
|
Estimate device location from multiple agent observations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
observations: List of observations from different agents
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LocationEstimate if successful, None if insufficient data
|
||||||
|
"""
|
||||||
|
if len(observations) < self.min_observations:
|
||||||
|
logger.debug(f"Insufficient observations: {len(observations)} < {self.min_observations}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Filter out observations with invalid coordinates
|
||||||
|
valid_obs = [
|
||||||
|
obs for obs in observations
|
||||||
|
if obs.agent_lat is not None and obs.agent_lon is not None
|
||||||
|
and -90 <= obs.agent_lat <= 90 and -180 <= obs.agent_lon <= 180
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(valid_obs) < self.min_observations:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Convert RSSI to estimated distances
|
||||||
|
distances = []
|
||||||
|
for obs in valid_obs:
|
||||||
|
dist = self.path_loss.rssi_to_distance(obs.rssi, obs.frequency_mhz)
|
||||||
|
distances.append(dist)
|
||||||
|
|
||||||
|
# Use weighted centroid as initial estimate
|
||||||
|
# Weight by inverse distance (closer observations weighted more)
|
||||||
|
weights = [1.0 / max(d, 1.0) for d in distances]
|
||||||
|
total_weight = sum(weights)
|
||||||
|
|
||||||
|
initial_lat = sum(obs.agent_lat * w for obs, w in zip(valid_obs, weights)) / total_weight
|
||||||
|
initial_lon = sum(obs.agent_lon * w for obs, w in zip(valid_obs, weights)) / total_weight
|
||||||
|
|
||||||
|
# Iterative refinement using gradient descent
|
||||||
|
current_lat, current_lon = initial_lat, initial_lon
|
||||||
|
|
||||||
|
for iteration in range(self.max_iterations):
|
||||||
|
# Calculate gradient of error function
|
||||||
|
grad_lat = 0.0
|
||||||
|
grad_lon = 0.0
|
||||||
|
total_error = 0.0
|
||||||
|
|
||||||
|
for obs, expected_dist in zip(valid_obs, distances):
|
||||||
|
actual_dist = haversine_distance(
|
||||||
|
current_lat, current_lon,
|
||||||
|
obs.agent_lat, obs.agent_lon
|
||||||
|
)
|
||||||
|
|
||||||
|
error = actual_dist - expected_dist
|
||||||
|
total_error += error ** 2
|
||||||
|
|
||||||
|
if actual_dist > 0.1: # Avoid division by zero
|
||||||
|
# Gradient components
|
||||||
|
lat_diff = current_lat - obs.agent_lat
|
||||||
|
lon_diff = current_lon - obs.agent_lon
|
||||||
|
|
||||||
|
# Scale factor for lat/lon to meters
|
||||||
|
lat_scale = 111000.0
|
||||||
|
lon_scale = 111000.0 * math.cos(math.radians(current_lat))
|
||||||
|
|
||||||
|
grad_lat += error * (lat_diff * lat_scale) / actual_dist
|
||||||
|
grad_lon += error * (lon_diff * lon_scale) / actual_dist
|
||||||
|
|
||||||
|
# Adaptive learning rate based on error magnitude
|
||||||
|
rmse = math.sqrt(total_error / len(valid_obs))
|
||||||
|
learning_rate = min(0.5, rmse / 1000.0) / (iteration + 1)
|
||||||
|
|
||||||
|
# Update position
|
||||||
|
lat_delta = -learning_rate * grad_lat / 111000.0
|
||||||
|
lon_delta = -learning_rate * grad_lon / (111000.0 * math.cos(math.radians(current_lat)))
|
||||||
|
|
||||||
|
new_lat = current_lat + lat_delta
|
||||||
|
new_lon = current_lon + lon_delta
|
||||||
|
|
||||||
|
# Check convergence
|
||||||
|
movement = haversine_distance(current_lat, current_lon, new_lat, new_lon)
|
||||||
|
|
||||||
|
current_lat = new_lat
|
||||||
|
current_lon = new_lon
|
||||||
|
|
||||||
|
if movement < self.convergence_threshold:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Calculate accuracy estimate (average distance error)
|
||||||
|
total_error = 0.0
|
||||||
|
for obs, expected_dist in zip(valid_obs, distances):
|
||||||
|
actual_dist = haversine_distance(
|
||||||
|
current_lat, current_lon,
|
||||||
|
obs.agent_lat, obs.agent_lon
|
||||||
|
)
|
||||||
|
total_error += abs(actual_dist - expected_dist)
|
||||||
|
|
||||||
|
avg_error = total_error / len(valid_obs)
|
||||||
|
|
||||||
|
# Calculate confidence based on:
|
||||||
|
# - Number of observations (more is better)
|
||||||
|
# - Agreement between observations (lower error is better)
|
||||||
|
# - RSSI strength (stronger signals are more reliable)
|
||||||
|
|
||||||
|
obs_factor = min(1.0, len(valid_obs) / 4.0) # Max confidence at 4+ observations
|
||||||
|
error_factor = max(0.0, 1.0 - avg_error / 500.0) # Decreases as error increases
|
||||||
|
rssi_factor = min(1.0, max(0.0, (max(obs.rssi for obs in valid_obs) + 90) / 50.0))
|
||||||
|
|
||||||
|
confidence = (obs_factor * 0.3 + error_factor * 0.5 + rssi_factor * 0.2)
|
||||||
|
|
||||||
|
return LocationEstimate(
|
||||||
|
latitude=current_lat,
|
||||||
|
longitude=current_lon,
|
||||||
|
accuracy_meters=avg_error * 1.5, # Safety factor
|
||||||
|
confidence=confidence,
|
||||||
|
num_observations=len(valid_obs),
|
||||||
|
observations=valid_obs,
|
||||||
|
method="multilateration"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Device Location Tracker
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class DeviceLocationTracker:
|
||||||
|
"""
|
||||||
|
Track device locations over time using observations from multiple agents.
|
||||||
|
|
||||||
|
This class aggregates observations for each device (by identifier like MAC address)
|
||||||
|
and periodically computes location estimates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
trilateration: Optional[Trilateration] = None,
|
||||||
|
observation_window_seconds: float = 60.0,
|
||||||
|
min_observations: int = 2
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize device tracker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trilateration: Trilateration engine to use
|
||||||
|
observation_window_seconds: How long to keep observations
|
||||||
|
min_observations: Minimum observations needed for location
|
||||||
|
"""
|
||||||
|
self.trilateration = trilateration or Trilateration()
|
||||||
|
self.observation_window = observation_window_seconds
|
||||||
|
self.min_observations = min_observations
|
||||||
|
|
||||||
|
# device_id -> list of AgentObservation
|
||||||
|
self.observations: dict[str, List[AgentObservation]] = {}
|
||||||
|
|
||||||
|
# device_id -> latest LocationEstimate
|
||||||
|
self.locations: dict[str, LocationEstimate] = {}
|
||||||
|
|
||||||
|
def add_observation(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
agent_name: str,
|
||||||
|
agent_lat: float,
|
||||||
|
agent_lon: float,
|
||||||
|
rssi: float,
|
||||||
|
frequency_mhz: Optional[float] = None,
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
) -> Optional[LocationEstimate]:
|
||||||
|
"""
|
||||||
|
Add an observation and potentially update location estimate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Unique identifier for the device (MAC, BSSID, etc.)
|
||||||
|
agent_name: Name of the observing agent
|
||||||
|
agent_lat: Agent's GPS latitude
|
||||||
|
agent_lon: Agent's GPS longitude
|
||||||
|
rssi: Observed signal strength in dBm
|
||||||
|
frequency_mhz: Signal frequency (optional)
|
||||||
|
timestamp: Observation time (defaults to now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated LocationEstimate if enough data, None otherwise
|
||||||
|
"""
|
||||||
|
obs = AgentObservation(
|
||||||
|
agent_name=agent_name,
|
||||||
|
agent_lat=agent_lat,
|
||||||
|
agent_lon=agent_lon,
|
||||||
|
rssi=rssi,
|
||||||
|
frequency_mhz=frequency_mhz,
|
||||||
|
timestamp=timestamp or datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
if device_id not in self.observations:
|
||||||
|
self.observations[device_id] = []
|
||||||
|
|
||||||
|
self.observations[device_id].append(obs)
|
||||||
|
|
||||||
|
# Prune old observations
|
||||||
|
self._prune_observations(device_id)
|
||||||
|
|
||||||
|
# Try to compute/update location
|
||||||
|
return self._update_location(device_id)
|
||||||
|
|
||||||
|
def _prune_observations(self, device_id: str) -> None:
|
||||||
|
"""Remove observations older than the window."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff = now.timestamp() - self.observation_window
|
||||||
|
|
||||||
|
self.observations[device_id] = [
|
||||||
|
obs for obs in self.observations[device_id]
|
||||||
|
if obs.timestamp.timestamp() > cutoff
|
||||||
|
]
|
||||||
|
|
||||||
|
def _update_location(self, device_id: str) -> Optional[LocationEstimate]:
|
||||||
|
"""Compute location estimate from current observations."""
|
||||||
|
obs_list = self.observations.get(device_id, [])
|
||||||
|
|
||||||
|
# Get unique agents (use most recent observation per agent)
|
||||||
|
agent_obs: dict[str, AgentObservation] = {}
|
||||||
|
for obs in obs_list:
|
||||||
|
if obs.agent_name not in agent_obs or obs.timestamp > agent_obs[obs.agent_name].timestamp:
|
||||||
|
agent_obs[obs.agent_name] = obs
|
||||||
|
|
||||||
|
unique_observations = list(agent_obs.values())
|
||||||
|
|
||||||
|
if len(unique_observations) < self.min_observations:
|
||||||
|
return None
|
||||||
|
|
||||||
|
estimate = self.trilateration.estimate_location(unique_observations)
|
||||||
|
|
||||||
|
if estimate:
|
||||||
|
self.locations[device_id] = estimate
|
||||||
|
|
||||||
|
return estimate
|
||||||
|
|
||||||
|
def get_location(self, device_id: str) -> Optional[LocationEstimate]:
|
||||||
|
"""Get the latest location estimate for a device."""
|
||||||
|
return self.locations.get(device_id)
|
||||||
|
|
||||||
|
def get_all_locations(self) -> dict[str, LocationEstimate]:
|
||||||
|
"""Get all current location estimates."""
|
||||||
|
return dict(self.locations)
|
||||||
|
|
||||||
|
def get_devices_near(
|
||||||
|
self,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
radius_meters: float
|
||||||
|
) -> List[Tuple[str, LocationEstimate]]:
|
||||||
|
"""Find all tracked devices within radius of a point."""
|
||||||
|
results = []
|
||||||
|
for device_id, estimate in self.locations.items():
|
||||||
|
dist = haversine_distance(lat, lon, estimate.latitude, estimate.longitude)
|
||||||
|
if dist <= radius_meters:
|
||||||
|
results.append((device_id, estimate))
|
||||||
|
return results
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all observations and locations."""
|
||||||
|
self.observations.clear()
|
||||||
|
self.locations.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Convenience Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def estimate_location_from_observations(
|
||||||
|
observations: List[dict],
|
||||||
|
environment: str = 'outdoor'
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Convenience function to estimate location from a list of observation dicts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
observations: List of dicts with keys:
|
||||||
|
- agent_lat: float
|
||||||
|
- agent_lon: float
|
||||||
|
- rssi: float (dBm)
|
||||||
|
- agent_name: str (optional)
|
||||||
|
- frequency_mhz: float (optional)
|
||||||
|
environment: Path loss environment ('outdoor', 'indoor', etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Location dict or None if insufficient data
|
||||||
|
|
||||||
|
Example:
|
||||||
|
observations = [
|
||||||
|
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'},
|
||||||
|
{'agent_lat': 40.7135, 'agent_lon': -74.0055, 'rssi': -70, 'agent_name': 'node-2'},
|
||||||
|
{'agent_lat': 40.7120, 'agent_lon': -74.0050, 'rssi': -62, 'agent_name': 'node-3'},
|
||||||
|
]
|
||||||
|
result = estimate_location_from_observations(observations)
|
||||||
|
# result: {'latitude': 40.7130, 'longitude': -74.0056, 'accuracy_meters': 25, ...}
|
||||||
|
"""
|
||||||
|
obs_list = []
|
||||||
|
for obs in observations:
|
||||||
|
obs_list.append(AgentObservation(
|
||||||
|
agent_name=obs.get('agent_name', 'unknown'),
|
||||||
|
agent_lat=obs['agent_lat'],
|
||||||
|
agent_lon=obs['agent_lon'],
|
||||||
|
rssi=obs['rssi'],
|
||||||
|
frequency_mhz=obs.get('frequency_mhz')
|
||||||
|
))
|
||||||
|
|
||||||
|
trilat = Trilateration(
|
||||||
|
path_loss_model=PathLossModel(environment=environment)
|
||||||
|
)
|
||||||
|
|
||||||
|
estimate = trilat.estimate_location(obs_list)
|
||||||
|
return estimate.to_dict() if estimate else None
|
||||||