diff --git a/.gitignore b/.gitignore index 701993a..e7b6292 100644 --- a/.gitignore +++ b/.gitignore @@ -10,17 +10,17 @@ venv/ ENV/ uv.lock -# Logs -*.log -pager_messages.log - -# Local data -downloads/ -pgdata/ - -# Local data -downloads/ -pgdata/ +# Logs +*.log +pager_messages.log + +# Local data +downloads/ +pgdata/ + +# Local data +downloads/ +pgdata/ # IDE .idea/ @@ -42,4 +42,15 @@ build/ uv.lock *.db *.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 diff --git a/app.py b/app.py index 6c48dc8..01ca753 100644 --- a/app.py +++ b/app.py @@ -203,9 +203,14 @@ cleanup_manager.register(dsc_messages) # ============================================ @app.before_request -def require_login(): - # Routes that don't require login (to avoid infinite redirect loop) - allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check'] +def require_login(): + # Routes that don't require login (to avoid infinite redirect loop) + 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 'logged_in' not in session and request.endpoint not in allowed_routes: @@ -710,4 +715,4 @@ def main() -> None: debug=args.debug, threaded=True, load_dotenv=False, - ) + ) diff --git a/docs/DISTRIBUTED_AGENTS.md b/docs/DISTRIBUTED_AGENTS.md new file mode 100644 index 0000000..7aa9f99 --- /dev/null +++ b/docs/DISTRIBUTED_AGENTS.md @@ -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 + +``` + +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 | diff --git a/intercept_agent.cfg b/intercept_agent.cfg new file mode 100644 index 0000000..ad81d77 --- /dev/null +++ b/intercept_agent.cfg @@ -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 diff --git a/intercept_agent.py b/intercept_agent.py new file mode 100644 index 0000000..4f56c9e --- /dev/null +++ b/intercept_agent.py @@ -0,0 +1,3824 @@ +#!/usr/bin/env python3 +""" +INTERCEPT Agent - Remote node for distributed signal intelligence. + +This agent runs on remote nodes and exposes Intercept's capabilities via REST API. +It can push data to a central controller or respond to pull requests. + +Usage: + python intercept_agent.py [--port 8020] [--config intercept_agent.cfg] +""" + +from __future__ import annotations + +import argparse +import configparser +import json +import logging +import os +import queue +import re +import shutil +import signal +import socket +import subprocess +import sys +import threading +import time +from datetime import datetime, timezone +from http.server import HTTPServer, BaseHTTPRequestHandler +from socketserver import ThreadingMixIn +from typing import Any +from urllib.parse import urlparse, parse_qs + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Import dependency checking from Intercept utils +try: + from utils.dependencies import check_all_dependencies, check_tool, TOOL_DEPENDENCIES + HAS_DEPENDENCIES_MODULE = True +except ImportError: + HAS_DEPENDENCIES_MODULE = False + +# Import TSCM modules for consistent analysis (same as local mode) +try: + from utils.tscm.detector import ThreatDetector + from utils.tscm.correlation import CorrelationEngine + HAS_TSCM_MODULES = True +except ImportError: + HAS_TSCM_MODULES = False + ThreatDetector = None + CorrelationEngine = None + +# Import database functions for baseline support (same as local mode) +try: + from utils.database import get_tscm_baseline, get_active_tscm_baseline + HAS_BASELINE_DB = True +except ImportError: + HAS_BASELINE_DB = False + get_tscm_baseline = None + get_active_tscm_baseline = None + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s' +) +logger = logging.getLogger('intercept.agent') + +# Version +AGENT_VERSION = '1.0.0' + +# ============================================================================= +# Configuration +# ============================================================================= + +class AgentConfig: + """Agent configuration loaded from INI file or defaults.""" + + def __init__(self): + # Agent settings + self.name: str = socket.gethostname() + self.port: int = 8020 + self.allowed_ips: list[str] = [] + self.allow_cors: bool = False + + # Controller settings + self.controller_url: str = '' + self.controller_api_key: str = '' + self.push_enabled: bool = False + self.push_interval: int = 5 + + # Mode settings (all enabled by default) + self.modes_enabled: dict[str, bool] = { + '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, + } + + def load_from_file(self, filepath: str) -> bool: + """Load configuration from INI file.""" + if not os.path.isfile(filepath): + logger.warning(f"Config file not found: {filepath}") + return False + + parser = configparser.ConfigParser() + try: + parser.read(filepath) + + # Agent section + if parser.has_section('agent'): + if parser.has_option('agent', 'name'): + self.name = parser.get('agent', 'name') + if parser.has_option('agent', 'port'): + self.port = parser.getint('agent', 'port') + if parser.has_option('agent', 'allowed_ips'): + ips = parser.get('agent', 'allowed_ips') + if ips.strip(): + self.allowed_ips = [ip.strip() for ip in ips.split(',')] + if parser.has_option('agent', 'allow_cors'): + self.allow_cors = parser.getboolean('agent', 'allow_cors') + + # Controller section + if parser.has_section('controller'): + if parser.has_option('controller', 'url'): + self.controller_url = parser.get('controller', 'url').rstrip('/') + if parser.has_option('controller', 'api_key'): + self.controller_api_key = parser.get('controller', 'api_key') + if parser.has_option('controller', 'push_enabled'): + self.push_enabled = parser.getboolean('controller', 'push_enabled') + if parser.has_option('controller', 'push_interval'): + self.push_interval = parser.getint('controller', 'push_interval') + + # Modes section + if parser.has_section('modes'): + for mode in self.modes_enabled.keys(): + if parser.has_option('modes', mode): + self.modes_enabled[mode] = parser.getboolean('modes', mode) + + logger.info(f"Loaded configuration from {filepath}") + return True + + except Exception as e: + logger.error(f"Error loading config: {e}") + return False + + def to_dict(self) -> dict: + """Convert config to dictionary.""" + return { + 'name': self.name, + 'port': self.port, + 'allowed_ips': self.allowed_ips, + 'allow_cors': self.allow_cors, + 'controller_url': self.controller_url, + 'push_enabled': self.push_enabled, + 'push_interval': self.push_interval, + 'modes_enabled': self.modes_enabled, + } + + +# Global config +config = AgentConfig() + + +# ============================================================================= +# GPS Integration +# ============================================================================= + +class GPSManager: + """Manages GPS position via gpsd.""" + + def __init__(self): + self._client = None + self._position = None + self._lock = threading.Lock() + self._running = False + + @property + def position(self) -> dict | None: + """Get current GPS position.""" + with self._lock: + if self._position: + return { + 'lat': self._position.latitude, + 'lon': self._position.longitude, + 'altitude': self._position.altitude, + 'speed': self._position.speed, + 'heading': self._position.heading, + 'fix_quality': self._position.fix_quality, + } + return None + + def start(self, host: str = 'localhost', port: int = 2947) -> bool: + """Start GPS client connection to gpsd.""" + try: + from utils.gps import GPSDClient + self._client = GPSDClient(host, port) + self._client.add_callback(self._on_position_update) + success = self._client.start() + if success: + self._running = True + logger.info(f"GPS connected to gpsd at {host}:{port}") + return success + except ImportError: + logger.warning("GPS module not available") + return False + except Exception as e: + logger.error(f"Failed to start GPS: {e}") + return False + + def stop(self): + """Stop GPS client.""" + if self._client: + self._client.stop() + self._client = None + self._running = False + + def _on_position_update(self, position): + """Callback for GPS position updates.""" + with self._lock: + self._position = position + + @property + def is_running(self) -> bool: + return self._running + + +# Global GPS manager +gps_manager = GPSManager() + + +# ============================================================================= +# Controller Push Client +# ============================================================================= + +class ControllerPushClient(threading.Thread): + """Daemon thread that pushes scan data to the controller.""" + + def __init__(self, cfg: AgentConfig): + super().__init__() + self.daemon = True + self.cfg = cfg + self.queue: queue.Queue = queue.Queue(maxsize=200) + self.running = False + self.stop_event = threading.Event() + + def enqueue(self, scan_type: str, payload: dict, interface: str = None): + """Add data to push queue.""" + if not self.cfg.push_enabled or not self.cfg.controller_url: + return + + item = { + 'agent_name': self.cfg.name, + 'scan_type': scan_type, + 'interface': interface, + 'payload': payload, + 'received_at': datetime.now(timezone.utc).isoformat(), + 'attempts': 0, + } + + try: + self.queue.put_nowait(item) + except queue.Full: + logger.warning("Push queue full, dropping payload") + + def run(self): + """Main push loop.""" + import requests + + self.running = True + logger.info(f"Push client started, target: {self.cfg.controller_url}") + + while not self.stop_event.is_set(): + try: + item = self.queue.get(timeout=1.0) + except queue.Empty: + continue + + if item is None: + continue + + endpoint = f"{self.cfg.controller_url}/controller/api/ingest" + headers = {'Content-Type': 'application/json'} + if self.cfg.controller_api_key: + headers['X-API-Key'] = self.cfg.controller_api_key + + body = { + 'agent_name': item['agent_name'], + 'scan_type': item['scan_type'], + 'interface': item['interface'], + 'payload': item['payload'], + 'received_at': item['received_at'], + } + + try: + response = requests.post(endpoint, json=body, headers=headers, timeout=5) + if response.status_code >= 400: + raise RuntimeError(f"HTTP {response.status_code}") + logger.debug(f"Pushed {item['scan_type']} data to controller") + except Exception as e: + item['attempts'] += 1 + if item['attempts'] < 3 and not self.stop_event.is_set(): + try: + self.queue.put_nowait(item) + except queue.Full: + pass + else: + logger.warning(f"Failed to push after {item['attempts']} attempts: {e}") + finally: + self.queue.task_done() + + self.running = False + logger.info("Push client stopped") + + def stop(self): + """Stop the push client.""" + self.stop_event.set() + + +# Global push client +push_client: ControllerPushClient | None = None + + +# ============================================================================= +# Mode Manager - Uses Intercept's existing utilities and tools +# ============================================================================= + +class ModeManager: + """ + Manages mode state using Intercept's existing infrastructure. + + This assumes Intercept (or its utilities) is installed on the agent host. + The agent imports and uses the existing modules rather than reimplementing + tool execution logic. + """ + + def __init__(self): + self.running_modes: dict[str, dict] = {} + self.data_snapshots: dict[str, list] = {} + self.locks: dict[str, threading.Lock] = {} + self._capabilities: dict | None = None + # Process tracking per mode + self.processes: dict[str, subprocess.Popen] = {} + self.output_threads: dict[str, threading.Thread] = {} + self.stop_events: dict[str, threading.Event] = {} + # Data queues for each mode (for real-time collection) + self.data_queues: dict[str, queue.Queue] = {} + # WiFi-specific state + self.wifi_networks: dict[str, dict] = {} + self.wifi_clients: dict[str, dict] = {} + # ADS-B specific state + self.adsb_aircraft: dict[str, dict] = {} + # Bluetooth specific state + self.bluetooth_devices: dict[str, dict] = {} + # Lazy-loaded Intercept utilities + self._sdr_factory = None + self._dependencies = None + + def _get_sdr_factory(self): + """Lazy-load SDRFactory from Intercept's utils.""" + if self._sdr_factory is None: + try: + from utils.sdr import SDRFactory + self._sdr_factory = SDRFactory + except ImportError: + logger.warning("SDRFactory not available - SDR features disabled") + return self._sdr_factory + + def _get_dependencies(self): + """Lazy-load dependencies module from Intercept's utils.""" + if self._dependencies is None: + try: + from utils import dependencies + self._dependencies = dependencies + except ImportError: + logger.warning("Dependencies module not available") + return self._dependencies + + def _check_tool(self, tool_name: str) -> bool: + """Check if a tool is available using Intercept's dependency checker.""" + deps = self._get_dependencies() + if deps and hasattr(deps, 'check_tool'): + return deps.check_tool(tool_name) + # Fallback to simple which check + return shutil.which(tool_name) is not None + + def _get_tool_path(self, tool_name: str) -> str | None: + """Get tool path using Intercept's dependency module.""" + deps = self._get_dependencies() + if deps and hasattr(deps, 'get_tool_path'): + return deps.get_tool_path(tool_name) + return shutil.which(tool_name) + + def detect_capabilities(self) -> dict: + """Detect available tools and hardware using Intercept's utilities.""" + if self._capabilities is not None: + return self._capabilities + + capabilities = { + 'modes': {}, + 'devices': [], + 'interfaces': { + 'wifi_interfaces': [], + 'bt_adapters': [], + 'sdr_devices': [], + }, + 'agent_version': AGENT_VERSION, + 'gps': gps_manager.is_running, + 'gps_position': gps_manager.position, + 'tool_details': {}, # Detailed tool status + } + + # Detect interfaces using Intercept's TSCM device detection + self._detect_interfaces(capabilities) + + # Use Intercept's comprehensive dependency checking if available + if HAS_DEPENDENCIES_MODULE: + try: + dep_status = check_all_dependencies() + # Map dependency status to mode availability + mode_mapping = { + 'pager': 'pager', + 'sensor': 'sensor', + 'aircraft': 'adsb', + 'ais': 'ais', + 'acars': 'acars', + 'aprs': 'aprs', + 'wifi': 'wifi', + 'bluetooth': 'bluetooth', + 'tscm': 'tscm', + 'satellite': 'satellite', + } + for dep_mode, cap_mode in mode_mapping.items(): + if dep_mode in dep_status: + mode_info = dep_status[dep_mode] + # Check if mode is enabled in config + if not config.modes_enabled.get(cap_mode, True): + capabilities['modes'][cap_mode] = False + else: + capabilities['modes'][cap_mode] = mode_info['ready'] + # Store detailed tool info + capabilities['tool_details'][cap_mode] = { + 'name': mode_info['name'], + 'ready': mode_info['ready'], + 'missing_required': mode_info['missing_required'], + 'tools': mode_info['tools'], + } + # Handle modes not in dependencies.py + extra_modes = ['dsc', 'rtlamr', 'listening_post'] + extra_tools = { + 'dsc': ['rtl_fm'], + 'rtlamr': ['rtlamr'], + 'listening_post': ['rtl_fm'], + } + for mode in extra_modes: + if not config.modes_enabled.get(mode, True): + capabilities['modes'][mode] = False + else: + tools = extra_tools.get(mode, []) + capabilities['modes'][mode] = all( + check_tool(tool) for tool in tools + ) if tools else True + except Exception as e: + logger.warning(f"Dependency check failed, using fallback: {e}") + self._detect_capabilities_fallback(capabilities) + else: + self._detect_capabilities_fallback(capabilities) + + # Use Intercept's SDR detection + sdr_factory = self._get_sdr_factory() + if sdr_factory: + try: + devices = sdr_factory.detect_devices() + sdr_list = [] + for sdr in devices: + sdr_dict = sdr.to_dict() + # Create friendly display name + display_name = sdr.name + if sdr.serial and sdr.serial not in ('N/A', 'Unknown'): + display_name = f'{sdr.name} (SN: {sdr.serial[-8:]})' + sdr_dict['display_name'] = display_name + sdr_list.append(sdr_dict) + capabilities['devices'] = sdr_list + capabilities['interfaces']['sdr_devices'] = sdr_list + except Exception as e: + logger.warning(f"SDR device detection failed: {e}") + + self._capabilities = capabilities + return capabilities + + def _detect_interfaces(self, capabilities: dict): + """Detect WiFi interfaces and Bluetooth adapters.""" + import platform + + interfaces = capabilities.get('interfaces', {}) + + # Detect WiFi interfaces + if platform.system() == 'Darwin': # macOS + try: + result = subprocess.run( + ['networksetup', '-listallhardwareports'], + capture_output=True, text=True, timeout=5 + ) + lines = result.stdout.split('\n') + for i, line in enumerate(lines): + if 'Wi-Fi' in line or 'AirPort' in line: + port_name = line.replace('Hardware Port:', '').strip() + for j in range(i + 1, min(i + 3, len(lines))): + if 'Device:' in lines[j]: + device = lines[j].split('Device:')[1].strip() + interfaces['wifi_interfaces'].append({ + 'name': device, + 'display_name': f'{port_name} ({device})', + 'type': 'internal', + 'monitor_capable': False + }) + break + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + else: # Linux + try: + result = subprocess.run( + ['iw', 'dev'], + capture_output=True, text=True, timeout=5 + ) + current_iface = None + for line in result.stdout.split('\n'): + line = line.strip() + if line.startswith('Interface'): + current_iface = line.split()[1] + elif current_iface and 'type' in line: + iface_type = line.split()[-1] + interfaces['wifi_interfaces'].append({ + 'name': current_iface, + 'display_name': f'Wireless ({current_iface}) - {iface_type}', + 'type': iface_type, + 'monitor_capable': True + }) + current_iface = None + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + # Fall back to iwconfig + try: + result = subprocess.run( + ['iwconfig'], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.split('\n'): + if 'IEEE 802.11' in line: + iface = line.split()[0] + interfaces['wifi_interfaces'].append({ + 'name': iface, + 'display_name': f'Wireless ({iface})', + 'type': 'managed', + 'monitor_capable': True + }) + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + + # Detect Bluetooth adapters + if platform.system() == 'Linux': + try: + result = subprocess.run( + ['hciconfig'], + capture_output=True, text=True, timeout=5 + ) + blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE) + for block in blocks: + if block.strip(): + first_line = block.split('\n')[0] + match = re.match(r'(hci\d+):', first_line) + if match: + iface_name = match.group(1) + is_up = 'UP RUNNING' in block or '\tUP ' in block + interfaces['bt_adapters'].append({ + 'name': iface_name, + 'display_name': f'Bluetooth Adapter ({iface_name})', + 'type': 'hci', + 'status': 'up' if is_up else 'down' + }) + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + # Try bluetoothctl as fallback + try: + result = subprocess.run( + ['bluetoothctl', 'list'], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.split('\n'): + if 'Controller' in line: + parts = line.split() + if len(parts) >= 3: + addr = parts[1] + name = ' '.join(parts[2:]) if len(parts) > 2 else 'Bluetooth' + interfaces['bt_adapters'].append({ + 'name': addr, + 'display_name': f'{name} ({addr[-8:]})', + 'type': 'controller', + 'status': 'available' + }) + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + elif platform.system() == 'Darwin': + try: + result = subprocess.run( + ['system_profiler', 'SPBluetoothDataType'], + capture_output=True, text=True, timeout=10 + ) + bt_name = 'Built-in Bluetooth' + bt_addr = '' + for line in result.stdout.split('\n'): + if 'Address:' in line: + bt_addr = line.split('Address:')[1].strip() + break + interfaces['bt_adapters'].append({ + 'name': 'default', + 'display_name': f'{bt_name}' + (f' ({bt_addr[-8:]})' if bt_addr else ''), + 'type': 'macos', + 'status': 'available' + }) + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + interfaces['bt_adapters'].append({ + 'name': 'default', + 'display_name': 'Built-in Bluetooth', + 'type': 'macos', + 'status': 'available' + }) + + def _detect_capabilities_fallback(self, capabilities: dict): + """Fallback capability detection when dependencies module unavailable.""" + tool_checks = { + 'pager': ['rtl_fm', 'multimon-ng'], + 'sensor': ['rtl_433'], + 'adsb': ['dump1090'], + 'ais': ['AIS-catcher'], + 'acars': ['acarsdec'], + 'aprs': ['rtl_fm', 'direwolf'], + 'wifi': ['airmon-ng', 'airodump-ng'], + 'bluetooth': ['bluetoothctl'], + 'dsc': ['rtl_fm'], + 'rtlamr': ['rtlamr'], + 'satellite': [], + 'listening_post': ['rtl_fm'], + 'tscm': ['rtl_fm'], + } + + for mode, tools in tool_checks.items(): + if not config.modes_enabled.get(mode, True): + capabilities['modes'][mode] = False + continue + if not tools: + capabilities['modes'][mode] = True + continue + if mode == 'adsb': + capabilities['modes'][mode] = ( + self._check_tool('dump1090') or + self._check_tool('dump1090-fa') or + self._check_tool('readsb') + ) + else: + capabilities['modes'][mode] = all( + self._check_tool(tool) for tool in tools + ) + + def get_status(self) -> dict: + """Get overall agent status.""" + # Build running modes with device info for multi-SDR tracking + running_modes_detail = {} + for mode, info in self.running_modes.items(): + params = info.get('params', {}) + running_modes_detail[mode] = { + 'started_at': info.get('started_at'), + 'device': params.get('device', params.get('device_index', 0)), + } + + status = { + 'running_modes': list(self.running_modes.keys()), + 'running_modes_detail': running_modes_detail, # Include device info per mode + 'uptime': time.time() - _start_time, + 'push_enabled': config.push_enabled, + 'push_connected': push_client is not None and push_client.running, + 'gps': gps_manager.is_running, + } + # Include GPS position if available + gps_pos = gps_manager.position + if gps_pos: + status['gps_position'] = gps_pos + return status + + # Modes that use RTL-SDR devices + SDR_MODES = {'adsb', 'sensor', 'pager', 'ais', 'acars', 'dsc', 'rtlamr', 'listening_post'} + + def get_sdr_in_use(self, device: int = 0) -> str | None: + """Check if an SDR device is in use by another mode. + + Returns the mode name using the device, or None if available. + """ + for mode, info in self.running_modes.items(): + if mode in self.SDR_MODES: + mode_device = info.get('params', {}).get('device', 0) + # Normalize to int for comparison + try: + mode_device = int(mode_device) + except (ValueError, TypeError): + mode_device = 0 + if mode_device == device: + return mode + return None + + def start_mode(self, mode: str, params: dict) -> dict: + """Start a mode with given parameters.""" + if mode in self.running_modes: + return {'status': 'error', 'message': f'{mode} already running'} + + caps = self.detect_capabilities() + if not caps['modes'].get(mode, False): + return {'status': 'error', 'message': f'{mode} not available (missing tools)'} + + # Check SDR device conflicts for SDR-based modes + if mode in self.SDR_MODES: + device = params.get('device', 0) + try: + device = int(device) + except (ValueError, TypeError): + device = 0 + in_use_by = self.get_sdr_in_use(device) + if in_use_by: + return { + 'status': 'error', + 'message': f'SDR device {device} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' + } + + # Initialize lock if needed + if mode not in self.locks: + self.locks[mode] = threading.Lock() + + with self.locks[mode]: + try: + # Mode-specific start logic + result = self._start_mode_internal(mode, params) + if result.get('status') == 'started': + self.running_modes[mode] = { + 'started_at': datetime.now(timezone.utc).isoformat(), + 'params': params, + } + return result + except Exception as e: + logger.exception(f"Error starting {mode}") + return {'status': 'error', 'message': str(e)} + + def stop_mode(self, mode: str) -> dict: + """Stop a running mode.""" + if mode not in self.running_modes: + return {'status': 'not_running'} + + if mode not in self.locks: + self.locks[mode] = threading.Lock() + + with self.locks[mode]: + try: + result = self._stop_mode_internal(mode) + if mode in self.running_modes: + del self.running_modes[mode] + return result + except Exception as e: + logger.exception(f"Error stopping {mode}") + return {'status': 'error', 'message': str(e)} + + def get_mode_status(self, mode: str) -> dict: + """Get status of a specific mode.""" + if mode in self.running_modes: + info = { + 'running': True, + **self.running_modes[mode] + } + # Add mode-specific stats + if mode == 'adsb': + info['aircraft_count'] = len(self.adsb_aircraft) + elif mode == 'wifi': + info['network_count'] = len(self.wifi_networks) + info['client_count'] = len(self.wifi_clients) + elif mode == 'bluetooth': + info['device_count'] = len(self.bluetooth_devices) + elif mode == 'sensor': + info['reading_count'] = len(self.data_snapshots.get(mode, [])) + elif mode == 'ais': + info['vessel_count'] = len(getattr(self, 'ais_vessels', {})) + elif mode == 'aprs': + info['station_count'] = len(getattr(self, 'aprs_stations', {})) + elif mode == 'pager': + info['message_count'] = len(self.data_snapshots.get(mode, [])) + elif mode == 'acars': + info['message_count'] = len(self.data_snapshots.get(mode, [])) + elif mode == 'rtlamr': + info['reading_count'] = len(self.data_snapshots.get(mode, [])) + elif mode == 'tscm': + info['anomaly_count'] = len(getattr(self, 'tscm_anomalies', [])) + elif mode == 'satellite': + info['pass_count'] = len(self.data_snapshots.get(mode, [])) + elif mode == 'listening_post': + info['signal_count'] = len(getattr(self, 'listening_post_activity', [])) + info['current_freq'] = getattr(self, 'listening_post_current_freq', 0) + info['freqs_scanned'] = getattr(self, 'listening_post_freqs_scanned', 0) + return info + return {'running': False} + + def get_mode_data(self, mode: str) -> dict: + """Get current data snapshot for a mode.""" + data = { + 'mode': mode, + 'timestamp': datetime.now(timezone.utc).isoformat(), + } + + # Add GPS position + gps_pos = gps_manager.position + if gps_pos: + data['agent_gps'] = gps_pos + + # Mode-specific data + if mode == 'adsb': + data['data'] = list(self.adsb_aircraft.values()) + elif mode == 'wifi': + data['data'] = { + 'networks': list(self.wifi_networks.values()), + 'clients': list(self.wifi_clients.values()), + } + elif mode == 'bluetooth': + data['data'] = list(self.bluetooth_devices.values()) + elif mode == 'ais': + data['data'] = list(getattr(self, 'ais_vessels', {}).values()) + elif mode == 'aprs': + data['data'] = list(getattr(self, 'aprs_stations', {}).values()) + elif mode == 'tscm': + data['data'] = { + 'anomalies': getattr(self, 'tscm_anomalies', []), + 'baseline': getattr(self, 'tscm_baseline', {}), + 'wifi_devices': list(self.wifi_networks.values()), + 'bt_devices': list(self.bluetooth_devices.values()), + 'rf_signals': getattr(self, 'tscm_rf_signals', []), + } + elif mode == 'listening_post': + data['data'] = { + 'activity': getattr(self, 'listening_post_activity', []), + 'current_freq': getattr(self, 'listening_post_current_freq', 0), + 'freqs_scanned': getattr(self, 'listening_post_freqs_scanned', 0), + 'signal_count': len(getattr(self, 'listening_post_activity', [])), + } + elif mode == 'pager': + # Return recent pager messages + messages = self.data_snapshots.get(mode, []) + data['data'] = { + 'messages': messages[-50:] if len(messages) > 50 else messages, + 'total_count': len(messages), + } + elif mode == 'dsc': + # Return DSC messages + messages = getattr(self, 'dsc_messages', []) + data['data'] = { + 'messages': messages[-50:] if len(messages) > 50 else messages, + 'total_count': len(messages), + } + else: + data['data'] = self.data_snapshots.get(mode, []) + + return data + + # ========================================================================= + # Mode-specific implementations + # ========================================================================= + + def _start_mode_internal(self, mode: str, params: dict) -> dict: + """Internal mode start - dispatches to mode-specific handlers.""" + logger.info(f"Starting mode {mode} with params: {params}") + + # Initialize data structures + self.data_snapshots[mode] = [] + self.data_queues[mode] = queue.Queue(maxsize=500) + self.stop_events[mode] = threading.Event() + + # Dispatch to mode-specific handler + handlers = { + 'sensor': self._start_sensor, + 'adsb': self._start_adsb, + 'wifi': self._start_wifi, + 'bluetooth': self._start_bluetooth, + 'pager': self._start_pager, + 'ais': self._start_ais, + 'acars': self._start_acars, + 'aprs': self._start_aprs, + 'rtlamr': self._start_rtlamr, + 'dsc': self._start_dsc, + 'tscm': self._start_tscm, + 'satellite': self._start_satellite, + 'listening_post': self._start_listening_post, + } + + handler = handlers.get(mode) + if handler: + return handler(params) + + # Unknown mode + logger.warning(f"Unknown mode: {mode}") + return {'status': 'error', 'message': f'Unknown mode: {mode}'} + + def _stop_mode_internal(self, mode: str) -> dict: + """Internal mode stop - terminates processes and cleans up.""" + logger.info(f"Stopping mode {mode}") + + # Signal stop + if mode in self.stop_events: + self.stop_events[mode].set() + + # Terminate process if running + if mode in self.processes: + proc = self.processes[mode] + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + del self.processes[mode] + + # Wait for output thread + if mode in self.output_threads: + thread = self.output_threads[mode] + if thread and thread.is_alive(): + thread.join(timeout=2) + del self.output_threads[mode] + + # Clean up + if mode in self.stop_events: + del self.stop_events[mode] + if mode in self.data_queues: + del self.data_queues[mode] + if mode in self.data_snapshots: + del self.data_snapshots[mode] + + # Mode-specific cleanup + if mode == 'adsb': + self.adsb_aircraft.clear() + elif mode == 'wifi': + self.wifi_networks.clear() + self.wifi_clients.clear() + elif mode == 'bluetooth': + self.bluetooth_devices.clear() + elif mode == 'tscm': + # Clean up TSCM sub-threads + for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']: + if sub_thread_name in self.output_threads: + thread = self.output_threads[sub_thread_name] + if thread and thread.is_alive(): + thread.join(timeout=2) + del self.output_threads[sub_thread_name] + # Clear TSCM data + self.tscm_anomalies = [] + self.tscm_baseline = {} + self.tscm_rf_signals = [] + # Clear reported threat tracking sets + if hasattr(self, '_tscm_reported_wifi'): + self._tscm_reported_wifi.clear() + if hasattr(self, '_tscm_reported_bt'): + self._tscm_reported_bt.clear() + elif mode == 'dsc': + # Clear DSC data + if hasattr(self, 'dsc_messages'): + self.dsc_messages = [] + elif mode == 'pager': + # Pager uses two processes: multimon-ng (pager) and rtl_fm (pager_rtl) + # Kill the rtl_fm process as well + if 'pager_rtl' in self.processes: + rtl_proc = self.processes['pager_rtl'] + if rtl_proc and rtl_proc.poll() is None: + rtl_proc.terminate() + try: + rtl_proc.wait(timeout=3) + except subprocess.TimeoutExpired: + rtl_proc.kill() + del self.processes['pager_rtl'] + # Clear pager data + if hasattr(self, 'pager_messages'): + self.pager_messages = [] + elif mode == 'aprs': + # APRS uses two processes: decoder (aprs) and rtl_fm (aprs_rtl) + if 'aprs_rtl' in self.processes: + rtl_proc = self.processes['aprs_rtl'] + if rtl_proc and rtl_proc.poll() is None: + rtl_proc.terminate() + try: + rtl_proc.wait(timeout=3) + except subprocess.TimeoutExpired: + rtl_proc.kill() + del self.processes['aprs_rtl'] + elif mode == 'rtlamr': + # RTLAMR uses two processes: rtlamr and rtl_tcp (rtlamr_tcp) + if 'rtlamr_tcp' in self.processes: + tcp_proc = self.processes['rtlamr_tcp'] + if tcp_proc and tcp_proc.poll() is None: + tcp_proc.terminate() + try: + tcp_proc.wait(timeout=3) + except subprocess.TimeoutExpired: + tcp_proc.kill() + del self.processes['rtlamr_tcp'] + + return {'status': 'stopped', 'mode': mode} + + # ------------------------------------------------------------------------- + # SENSOR MODE (rtl_433) - Uses Intercept's SDR abstraction + # ------------------------------------------------------------------------- + + def _start_sensor(self, params: dict) -> dict: + """Start rtl_433 sensor mode using Intercept's SDR utilities.""" + freq = params.get('frequency', '433.92') + gain = params.get('gain') + device = params.get('device', '0') + ppm = params.get('ppm') + bias_t = params.get('bias_t', False) + sdr_type_str = params.get('sdr_type', 'rtlsdr') + + # Try to use Intercept's SDR abstraction layer + sdr_factory = self._get_sdr_factory() + if sdr_factory: + try: + from utils.sdr import SDRType + sdr_type = SDRType(sdr_type_str) + sdr_device = sdr_factory.create_default_device(sdr_type, index=int(device)) + builder = sdr_factory.get_builder(sdr_type) + + # Use the builder to construct the command properly + cmd = builder.build_ism_command( + device=sdr_device, + frequency_mhz=float(freq), + gain=float(gain) if gain else None, + ppm=int(ppm) if ppm else None, + bias_t=bias_t + ) + logger.info(f"Starting sensor (via SDR abstraction): {' '.join(cmd)}") + + except Exception as e: + logger.warning(f"SDR abstraction failed, falling back to direct command: {e}") + cmd = self._build_sensor_command_fallback(freq, gain, device, ppm) + else: + # Fallback: build command directly + cmd = self._build_sensor_command_fallback(freq, gain, device, ppm) + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.processes['sensor'] = proc + + # Wait briefly to verify process started successfully + time.sleep(0.5) + if proc.poll() is not None: + stderr_output = proc.stderr.read().decode('utf-8', errors='replace') + del self.processes['sensor'] + return {'status': 'error', 'message': f'rtl_433 failed to start: {stderr_output[:200]}'} + + # Start output reader thread + thread = threading.Thread( + target=self._sensor_output_reader, + args=(proc,), + daemon=True + ) + thread.start() + self.output_threads['sensor'] = thread + + return { + 'status': 'started', + 'mode': 'sensor', + 'command': ' '.join(cmd), + 'gps_enabled': gps_manager.is_running + } + + except FileNotFoundError: + return {'status': 'error', 'message': 'rtl_433 not found. Install via: apt install rtl-433'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def _build_sensor_command_fallback(self, freq, gain, device, ppm) -> list: + """Build rtl_433 command without SDR abstraction.""" + cmd = ['rtl_433', '-F', 'json'] + if freq: + cmd.extend(['-f', f'{freq}M']) + if gain and str(gain) != '0': + cmd.extend(['-g', str(gain)]) + if device and str(device) != '0': + cmd.extend(['-d', str(device)]) + if ppm and str(ppm) != '0': + cmd.extend(['-p', str(ppm)]) + return cmd + + def _sensor_output_reader(self, proc: subprocess.Popen): + """Read rtl_433 JSON output and collect data.""" + mode = 'sensor' + stop_event = self.stop_events.get(mode) + + try: + for line in iter(proc.stdout.readline, b''): + if stop_event and stop_event.is_set(): + break + + line = line.decode('utf-8', errors='replace').strip() + if not line: + continue + + try: + data = json.loads(line) + data['type'] = 'sensor' + data['received_at'] = datetime.now(timezone.utc).isoformat() + + # Add GPS if available + gps_pos = gps_manager.position + if gps_pos: + data['agent_gps'] = gps_pos + + # Store in snapshot (keep last 100) + snapshots = self.data_snapshots.get(mode, []) + snapshots.append(data) + if len(snapshots) > 100: + snapshots = snapshots[-100:] + self.data_snapshots[mode] = snapshots + + logger.debug(f"Sensor data: {data.get('model', 'Unknown')}") + + except json.JSONDecodeError: + pass # Not JSON, ignore + + except Exception as e: + logger.error(f"Sensor output reader error: {e}") + finally: + proc.wait() + logger.info("Sensor output reader stopped") + + # ------------------------------------------------------------------------- + # ADS-B MODE (dump1090) - Uses Intercept's SDR abstraction + # ------------------------------------------------------------------------- + + def _start_adsb(self, params: dict) -> dict: + """Start dump1090 ADS-B mode using Intercept's utilities.""" + gain = params.get('gain', '40') + device = params.get('device', '0') + bias_t = params.get('bias_t', False) + sdr_type_str = params.get('sdr_type', 'rtlsdr') + remote_sbs_host = params.get('remote_sbs_host') + remote_sbs_port = params.get('remote_sbs_port', 30003) + + # If remote SBS host provided, just connect to it + if remote_sbs_host: + return self._start_adsb_sbs_connection(remote_sbs_host, remote_sbs_port) + + # Check if dump1090 already running on port 30003 + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1.0) + result = sock.connect_ex(('localhost', 30003)) + sock.close() + if result == 0: + logger.info("dump1090 already running, connecting to SBS port") + return self._start_adsb_sbs_connection('localhost', 30003) + except Exception: + pass + + # Try using Intercept's SDR abstraction for building the command + sdr_factory = self._get_sdr_factory() + cmd = None + + if sdr_factory: + try: + from utils.sdr import SDRType + sdr_type = SDRType(sdr_type_str) + sdr_device = sdr_factory.create_default_device(sdr_type, index=int(device)) + builder = sdr_factory.get_builder(sdr_type) + + # Use the builder to construct dump1090 command + cmd = builder.build_adsb_command( + device=sdr_device, + gain=float(gain) if gain else None, + bias_t=bias_t + ) + logger.info(f"Starting ADS-B (via SDR abstraction): {' '.join(cmd)}") + + except Exception as e: + logger.warning(f"SDR abstraction failed for ADS-B: {e}") + + if not cmd: + # Fallback: find dump1090 manually and build command + dump1090_path = self._find_dump1090() + if not dump1090_path: + return {'status': 'error', 'message': 'dump1090 not found. Install via: apt install dump1090-fa'} + + cmd = [dump1090_path, '--net', '--quiet'] + if gain: + cmd.extend(['--gain', str(gain)]) + if device and str(device) != '0': + cmd.extend(['--device-index', str(device)]) + + logger.info(f"Starting dump1090: {' '.join(cmd)}") + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + start_new_session=True + ) + self.processes['adsb'] = proc + + # Wait for dump1090 to start + time.sleep(2) + + if proc.poll() is not None: + stderr = proc.stderr.read().decode('utf-8', errors='ignore') + return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'} + + # Connect to SBS port + return self._start_adsb_sbs_connection('localhost', 30003) + + except FileNotFoundError: + return {'status': 'error', 'message': 'dump1090 not found'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def _find_dump1090(self) -> str | None: + """Find dump1090 binary using Intercept's dependency module or fallback.""" + # Try Intercept's tool path finder first + for name in ['dump1090', 'dump1090-fa', 'dump1090-mutability', 'readsb']: + path = self._get_tool_path(name) + if path: + return path + + # Fallback: check common installation paths + common_paths = [ + '/opt/homebrew/bin/dump1090', + '/opt/homebrew/bin/dump1090-fa', + '/usr/local/bin/dump1090', + '/usr/local/bin/dump1090-fa', + '/usr/bin/dump1090', + '/usr/bin/dump1090-fa', + ] + for path in common_paths: + if os.path.isfile(path) and os.access(path, os.X_OK): + return path + return None + + def _start_adsb_sbs_connection(self, host: str, port: int) -> dict: + """Connect to SBS port and start parsing.""" + thread = threading.Thread( + target=self._adsb_sbs_reader, + args=(host, port), + daemon=True + ) + thread.start() + self.output_threads['adsb'] = thread + + return { + 'status': 'started', + 'mode': 'adsb', + 'sbs_source': f'{host}:{port}', + 'gps_enabled': gps_manager.is_running + } + + def _adsb_sbs_reader(self, host: str, port: int): + """Read and parse SBS data from dump1090.""" + mode = 'adsb' + stop_event = self.stop_events.get(mode) + retry_count = 0 + max_retries = 5 + + while not (stop_event and stop_event.is_set()): + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5.0) + sock.connect((host, port)) + logger.info(f"Connected to SBS at {host}:{port}") + retry_count = 0 + + buffer = "" + sock.settimeout(1.0) + + while not (stop_event and stop_event.is_set()): + try: + data = sock.recv(4096).decode('utf-8', errors='ignore') + if not data: + break + buffer += data + + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + self._parse_sbs_line(line.strip()) + + except socket.timeout: + continue + + sock.close() + + except Exception as e: + logger.warning(f"SBS connection error: {e}") + retry_count += 1 + if retry_count >= max_retries: + logger.error("Max SBS retries reached, stopping") + break + time.sleep(2) + + logger.info("ADS-B SBS reader stopped") + + def _parse_sbs_line(self, line: str): + """Parse SBS format line and update aircraft dict.""" + if not line: + return + + parts = line.split(',') + if len(parts) < 11 or parts[0] != 'MSG': + return + + msg_type = parts[1] + icao = parts[4].upper() + if not icao: + return + + aircraft = self.adsb_aircraft.get(icao) or {'icao': icao} + aircraft['last_seen'] = datetime.now(timezone.utc).isoformat() + + # Add GPS + gps_pos = gps_manager.position + if gps_pos: + aircraft['agent_gps'] = gps_pos + + try: + if msg_type == '1' and len(parts) > 10: + callsign = parts[10].strip() + if callsign: + aircraft['callsign'] = callsign + + elif msg_type == '3' and len(parts) > 15: + if parts[11]: + aircraft['altitude'] = int(float(parts[11])) + if parts[14] and parts[15]: + aircraft['lat'] = float(parts[14]) + aircraft['lon'] = float(parts[15]) + + elif msg_type == '4' and len(parts) > 16: + if parts[12]: + aircraft['speed'] = int(float(parts[12])) + if parts[13]: + aircraft['heading'] = int(float(parts[13])) + if parts[16]: + aircraft['vertical_rate'] = int(float(parts[16])) + + elif msg_type == '5' and len(parts) > 11: + if parts[10]: + callsign = parts[10].strip() + if callsign: + aircraft['callsign'] = callsign + if parts[11]: + aircraft['altitude'] = int(float(parts[11])) + + elif msg_type == '6' and len(parts) > 17: + if parts[17]: + aircraft['squawk'] = parts[17] + + except (ValueError, IndexError): + pass + + self.adsb_aircraft[icao] = aircraft + + # ------------------------------------------------------------------------- + # WIFI MODE (airodump-ng) - Uses Intercept's utilities + # ------------------------------------------------------------------------- + + def _start_wifi(self, params: dict) -> dict: + """Start WiFi scanning using Intercept's UnifiedWiFiScanner.""" + interface = params.get('interface') + channel = params.get('channel') + band = params.get('band', 'abg') + scan_type = params.get('scan_type', 'deep') + + # Handle quick scan - returns results synchronously + if scan_type == 'quick': + return self._wifi_quick_scan(interface) + + # Deep scan - use Intercept's UnifiedWiFiScanner + try: + from utils.wifi.scanner import get_wifi_scanner + scanner = get_wifi_scanner(interface) + + # Store scanner reference + self._wifi_scanner_instance = scanner + + # Check capabilities + caps = scanner.check_capabilities() + if not caps.can_deep_scan: + return {'status': 'error', 'message': f'Deep scan not available: {", ".join(caps.issues)}'} + + # Convert band parameter + if band == 'abg': + scan_band = 'all' + elif band == 'bg': + scan_band = '2.4' + elif band == 'a': + scan_band = '5' + else: + scan_band = 'all' + + # Start deep scan + if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel): + # Start thread to sync data to agent's dictionaries + thread = threading.Thread( + target=self._wifi_data_sync, + args=(scanner,), + daemon=True + ) + thread.start() + self.output_threads['wifi'] = thread + + return { + 'status': 'started', + 'mode': 'wifi', + 'interface': interface, + 'gps_enabled': gps_manager.is_running + } + else: + return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'} + + except ImportError: + # Fallback to direct airodump-ng + return self._start_wifi_fallback(interface, channel, band) + except Exception as e: + logger.error(f"WiFi scanner error: {e}") + return {'status': 'error', 'message': str(e)} + + def _wifi_data_sync(self, scanner): + """Sync WiFi scanner data to agent's data structures.""" + mode = 'wifi' + stop_event = self.stop_events.get(mode) + + while not (stop_event and stop_event.is_set()): + try: + gps_position = gps_manager.position + + # Sync access points + for ap in scanner.access_points: + net = ap.to_dict() + if gps_position: + net['agent_gps'] = gps_position + self.wifi_networks[ap.bssid.upper()] = net + + # Sync clients + for client in scanner.clients: + client_data = client.to_dict() + if gps_position: + client_data['agent_gps'] = gps_position + self.wifi_clients[client.mac.upper()] = client_data + + time.sleep(2) + except Exception as e: + logger.debug(f"WiFi sync error: {e}") + time.sleep(2) + + # Stop scanner when done + if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance: + self._wifi_scanner_instance.stop_deep_scan() + + def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict: + """Fallback WiFi deep scan using airodump-ng directly.""" + if not interface: + return {'status': 'error', 'message': 'WiFi interface required'} + + # Validate interface + try: + from utils.validation import validate_network_interface + interface = validate_network_interface(interface) + except (ImportError, ValueError) as e: + if not os.path.exists(f'/sys/class/net/{interface}'): + return {'status': 'error', 'message': f'Interface {interface} not found'} + + csv_path = '/tmp/intercept_agent_wifi' + for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']: + try: + os.remove(f) + except OSError: + pass + + airodump_path = self._get_tool_path('airodump-ng') + if not airodump_path: + return {'status': 'error', 'message': 'airodump-ng not found'} + + output_formats = 'csv,gps' if gps_manager.is_running else 'csv' + cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band] + if gps_manager.is_running: + cmd.append('--gpsd') + if channel: + cmd.extend(['-c', str(channel)]) + cmd.append(interface) + + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.processes['wifi'] = proc + + time.sleep(0.5) + if proc.poll() is not None: + stderr = proc.stderr.read().decode('utf-8', errors='ignore') + return {'status': 'error', 'message': f'airodump-ng failed: {stderr[:200]}'} + + thread = threading.Thread(target=self._wifi_csv_reader, args=(csv_path,), daemon=True) + thread.start() + self.output_threads['wifi'] = thread + + return {'status': 'started', 'mode': 'wifi', 'interface': interface, 'gps_enabled': gps_manager.is_running} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def _wifi_quick_scan(self, interface: str | None) -> dict: + """ + Perform a quick one-shot WiFi scan using system tools. + + Uses nmcli, iw, or iwlist (no monitor mode required). + Returns results synchronously. + """ + try: + from utils.wifi.scanner import get_wifi_scanner + scanner = get_wifi_scanner() + result = scanner.quick_scan(interface=interface, timeout=15.0) + + if result.error: + return { + 'status': 'error', + 'message': result.error, + 'warnings': result.warnings + } + + # Convert access points to dict format + networks = [] + gps_position = gps_manager.position + for ap in result.access_points: + net = ap.to_dict() + # Add agent GPS if available + if gps_position: + net['agent_gps'] = gps_position + networks.append(net) + + return { + 'status': 'success', + 'scan_type': 'quick', + 'access_points': networks, + 'networks': networks, # Alias for compatibility + 'network_count': len(networks), + 'warnings': result.warnings, + 'gps_enabled': gps_manager.is_running, + 'agent_gps': gps_position + } + + except ImportError: + # Fallback: simple nmcli scan + return self._wifi_quick_scan_fallback(interface) + except Exception as e: + logger.exception("Quick WiFi scan failed") + return {'status': 'error', 'message': str(e)} + + def _wifi_quick_scan_fallback(self, interface: str | None) -> dict: + """Fallback quick scan using nmcli directly.""" + nmcli_path = shutil.which('nmcli') + if not nmcli_path: + return {'status': 'error', 'message': 'nmcli not found. Install NetworkManager.'} + + try: + # Trigger rescan + subprocess.run( + [nmcli_path, 'device', 'wifi', 'rescan'], + capture_output=True, + timeout=5 + ) + + # Get results + cmd = [nmcli_path, '-t', '-f', 'BSSID,SSID,CHAN,SIGNAL,SECURITY', 'device', 'wifi', 'list'] + if interface: + cmd.extend(['ifname', interface]) + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + + if result.returncode != 0: + return {'status': 'error', 'message': f'nmcli failed: {result.stderr}'} + + networks = [] + gps_position = gps_manager.position + for line in result.stdout.strip().split('\n'): + if not line.strip(): + continue + parts = line.split(':') + if len(parts) >= 5: + net = { + 'bssid': parts[0], + 'essid': parts[1], + 'channel': int(parts[2]) if parts[2].isdigit() else 0, + 'signal': int(parts[3]) if parts[3].isdigit() else 0, + 'rssi_current': int(parts[3]) - 100 if parts[3].isdigit() else -100, # Convert % to dBm approx + 'security': parts[4], + } + if gps_position: + net['agent_gps'] = gps_position + networks.append(net) + + return { + 'status': 'success', + 'scan_type': 'quick', + 'access_points': networks, + 'networks': networks, + 'network_count': len(networks), + 'warnings': ['Using fallback nmcli scanner'], + 'gps_enabled': gps_manager.is_running, + 'agent_gps': gps_position + } + + except subprocess.TimeoutExpired: + return {'status': 'error', 'message': 'nmcli scan timed out'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def _wifi_csv_reader(self, csv_path: str): + """Periodically parse airodump-ng CSV and GPS output.""" + mode = 'wifi' + stop_event = self.stop_events.get(mode) + csv_file = csv_path + '-01.csv' + gps_file = csv_path + '-01.gps' + + while not (stop_event and stop_event.is_set()): + if os.path.exists(csv_file): + try: + # Parse GPS file for accurate coordinates (if available) + gps_data = self._parse_airodump_gps(gps_file) if os.path.exists(gps_file) else None + + networks, clients = self._parse_airodump_csv(csv_file, gps_data) + self.wifi_networks = networks + self.wifi_clients = clients + except Exception as e: + logger.error(f"CSV parse error: {e}") + + time.sleep(2) + + logger.info("WiFi CSV reader stopped") + + def _parse_airodump_gps(self, gps_path: str) -> dict | None: + """ + Parse airodump-ng GPS file for accurate coordinates. + + Format: + + + + + ... + + + Returns the most recent GPS point. + """ + try: + import xml.etree.ElementTree as ET + tree = ET.parse(gps_path) + root = tree.getroot() + + # Get the last (most recent) GPS point + gps_points = root.findall('.//gps-point') + if gps_points: + last_point = gps_points[-1] + lat = last_point.get('lat') + lon = last_point.get('lon') + alt = last_point.get('alt') + + if lat and lon: + return { + 'lat': float(lat), + 'lon': float(lon), + 'altitude': float(alt) if alt else None, + 'source': 'airodump_gps' + } + except Exception as e: + logger.debug(f"GPS file parse error: {e}") + + return None + + def _parse_airodump_csv(self, csv_path: str, gps_data: dict | None = None) -> tuple[dict, dict]: + """Parse airodump-ng CSV file using Intercept's existing parser.""" + networks = {} + clients = {} + + try: + # Use Intercept's robust airodump parser (handles edge cases, proper CSV parsing) + from utils.wifi.parsers.airodump import parse_airodump_csv + network_obs, client_list = parse_airodump_csv(csv_path) + + # Convert WiFiObservation objects to dicts for agent format + for obs in network_obs: + networks[obs.bssid] = { + 'bssid': obs.bssid, + 'essid': obs.essid or 'Hidden', + 'channel': obs.channel, + 'frequency_mhz': obs.frequency_mhz, + 'signal': obs.rssi, + 'security': obs.security, + 'cipher': obs.cipher, + 'auth': obs.auth, + 'vendor': obs.vendor, + 'beacon_count': obs.beacon_count, + 'data_count': obs.data_count, + 'band': obs.band, + 'last_seen': datetime.now(timezone.utc).isoformat(), + } + + # Convert client dicts (already in dict format from parser) + for client in client_list: + mac = client.get('mac') + if mac: + clients[mac] = { + 'mac': mac, + 'signal': client.get('rssi'), + 'bssid': client.get('bssid'), + 'probes': ','.join(client.get('probed_essids', [])), + 'packets': client.get('packets', 0), + 'last_seen': datetime.now(timezone.utc).isoformat(), + } + + logger.debug(f"Parsed {len(networks)} networks, {len(clients)} clients") + + except ImportError: + logger.warning("Intercept WiFi parser not available, using fallback") + # Fallback: simple parsing if running standalone + try: + with open(csv_path, 'r', errors='replace') as f: + content = f.read() + for section in content.split('\n\n'): + lines = section.strip().split('\n') + if not lines: + continue + header = lines[0] + if 'BSSID' in header and 'ESSID' in header: + for line in lines[1:]: + parts = [p.strip() for p in line.split(',')] + if len(parts) >= 14 and ':' in parts[0]: + networks[parts[0]] = { + 'bssid': parts[0], + 'channel': int(parts[3]) if parts[3].lstrip('-').isdigit() else None, + 'signal': int(parts[8]) if parts[8].lstrip('-').isdigit() else None, + 'security': parts[5], + 'essid': parts[13] or 'Hidden', + 'last_seen': datetime.now(timezone.utc).isoformat(), + } + elif 'Station MAC' in header: + for line in lines[1:]: + parts = [p.strip() for p in line.split(',')] + if len(parts) >= 6 and ':' in parts[0]: + clients[parts[0]] = { + 'mac': parts[0], + 'signal': int(parts[3]) if parts[3].lstrip('-').isdigit() else None, + 'bssid': parts[5] if ':' in parts[5] else None, + 'probes': parts[6] if len(parts) > 6 else '', + 'last_seen': datetime.now(timezone.utc).isoformat(), + } + except Exception as e: + logger.error(f"Fallback CSV parse error: {e}") + + except Exception as e: + logger.error(f"Error parsing CSV: {e}") + + # Add GPS to all entries + # Prefer GPS from airodump's .gps file (more accurate timestamp) + # Fall back to GPSManager if no .gps file data + if gps_data: + # Use GPS coordinates from airodump's GPS file + gps_pos = { + 'lat': gps_data['lat'], + 'lon': gps_data['lon'], + 'altitude': gps_data.get('altitude'), + 'source': 'airodump_gps', # Mark as from airodump GPS file + } + logger.debug(f"Using airodump GPS: {gps_data['lat']:.6f}, {gps_data['lon']:.6f}") + else: + # Fall back to GPSManager position + gps_pos = gps_manager.position + + if gps_pos: + for net in networks.values(): + net['agent_gps'] = gps_pos + for client in clients.values(): + client['agent_gps'] = gps_pos + + return networks, clients + + # ------------------------------------------------------------------------- + # BLUETOOTH MODE + # ------------------------------------------------------------------------- + + def _start_bluetooth(self, params: dict) -> dict: + """Start Bluetooth scanning using Intercept's BluetoothScanner.""" + adapter = params.get('adapter', 'hci0') + mode_param = params.get('mode', 'auto') + duration = params.get('duration') + + try: + # Use Intercept's BluetoothScanner + from utils.bluetooth.scanner import BluetoothScanner + scanner = BluetoothScanner(adapter_id=adapter) + + # Store scanner reference + self._bluetooth_scanner_instance = scanner + + # Set callback for device updates + def on_device_updated(device): + # Convert to agent's format and store + self.bluetooth_devices[device.address.upper()] = { + 'mac': device.address.upper(), + 'name': device.name, + 'rssi': device.rssi_current, + 'protocol': device.protocol, + 'last_seen': device.last_seen.isoformat() if device.last_seen else None, + 'first_seen': device.first_seen.isoformat() if device.first_seen else None, + 'agent_gps': gps_manager.position + } + + scanner.set_on_device_updated(on_device_updated) + + # Start scanning + if scanner.start_scan(mode=mode_param, duration_s=duration): + # Start thread to sync device data + thread = threading.Thread( + target=self._bluetooth_data_sync, + args=(scanner,), + daemon=True + ) + thread.start() + self.output_threads['bluetooth'] = thread + + return { + 'status': 'started', + 'mode': 'bluetooth', + 'adapter': adapter, + 'backend': scanner.get_status().backend, + 'gps_enabled': gps_manager.is_running + } + else: + return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start scan'} + + except ImportError: + # Fallback to direct bluetoothctl if scanner not available + return self._start_bluetooth_fallback(adapter) + except Exception as e: + logger.error(f"Bluetooth scanner error: {e}") + return {'status': 'error', 'message': str(e)} + + def _bluetooth_data_sync(self, scanner): + """Sync Bluetooth scanner data to agent's data structures.""" + mode = 'bluetooth' + stop_event = self.stop_events.get(mode) + + while not (stop_event and stop_event.is_set()): + try: + # Get devices from scanner + devices = scanner.get_devices() + for device in devices: + self.bluetooth_devices[device.address.upper()] = { + 'mac': device.address.upper(), + 'name': device.name, + 'rssi': device.rssi_current, + 'protocol': device.protocol, + 'last_seen': device.last_seen.isoformat() if device.last_seen else None, + 'agent_gps': gps_manager.position + } + time.sleep(1) + except Exception as e: + logger.debug(f"Bluetooth sync error: {e}") + time.sleep(1) + + # Stop scanner when done + if hasattr(self, '_bluetooth_scanner_instance') and self._bluetooth_scanner_instance: + self._bluetooth_scanner_instance.stop_scan() + + def _start_bluetooth_fallback(self, adapter: str) -> dict: + """Fallback Bluetooth scanning using bluetoothctl directly.""" + if not shutil.which('bluetoothctl'): + return {'status': 'error', 'message': 'bluetoothctl not found'} + + thread = threading.Thread( + target=self._bluetooth_scanner_fallback, + args=(adapter,), + daemon=True + ) + thread.start() + self.output_threads['bluetooth'] = thread + + return { + 'status': 'started', + 'mode': 'bluetooth', + 'adapter': adapter, + 'backend': 'bluetoothctl', + 'gps_enabled': gps_manager.is_running + } + + def _bluetooth_scanner_fallback(self, adapter: str): + """Fallback scan using bluetoothctl directly.""" + mode = 'bluetooth' + stop_event = self.stop_events.get(mode) + + try: + proc = subprocess.Popen( + ['bluetoothctl'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.processes['bluetooth'] = proc + + proc.stdin.write(b'scan on\n') + proc.stdin.flush() + + while not (stop_event and stop_event.is_set()): + line = proc.stdout.readline() + if not line: + break + + line = line.decode('utf-8', errors='replace').strip() + if 'Device' in line: + self._parse_bluetooth_line(line) + + time.sleep(0.1) + + proc.stdin.write(b'scan off\n') + proc.stdin.write(b'exit\n') + proc.stdin.flush() + proc.wait(timeout=2) + + except Exception as e: + logger.error(f"Bluetooth scanner error: {e}") + finally: + logger.info("Bluetooth scanner stopped") + + def _parse_bluetooth_line(self, line: str): + """Parse bluetoothctl output line.""" + import re + + # Match device address (MAC) + mac_match = re.search(r'([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})', line) + if not mac_match: + return + + mac = mac_match.group(1).upper() + device = self.bluetooth_devices.get(mac) or {'mac': mac} + device['last_seen'] = datetime.now(timezone.utc).isoformat() + + # Extract name + if '[NEW]' in line or '[CHG]' in line and 'Name:' not in line: + # Try to get name after MAC + parts = line.split(mac) + if len(parts) > 1: + name = parts[1].strip() + if name and not name.startswith('RSSI') and not name.startswith('ManufacturerData'): + device['name'] = name + + # Extract RSSI + rssi_match = re.search(r'RSSI:\s*(-?\d+)', line) + if rssi_match: + device['rssi'] = int(rssi_match.group(1)) + + # Add GPS + gps_pos = gps_manager.position + if gps_pos: + device['agent_gps'] = gps_pos + + self.bluetooth_devices[mac] = device + + # ------------------------------------------------------------------------- + # PAGER MODE (rtl_fm | multimon-ng) + # ------------------------------------------------------------------------- + + def _start_pager(self, params: dict) -> dict: + """Start POCSAG/FLEX pager decoding using rtl_fm | multimon-ng.""" + freq = params.get('frequency', '929.6125') + gain = params.get('gain', '0') + device = params.get('device', '0') + ppm = params.get('ppm', '0') + squelch = params.get('squelch', '0') + protocols = params.get('protocols', ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']) + + # Validate tools + rtl_fm_path = self._get_tool_path('rtl_fm') + multimon_path = self._get_tool_path('multimon-ng') + if not rtl_fm_path: + return {'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr.'} + if not multimon_path: + return {'status': 'error', 'message': 'multimon-ng not found. Install multimon-ng.'} + + # Build rtl_fm command for FM demodulation at 22050 Hz + rtl_fm_cmd = [ + rtl_fm_path, + '-f', f'{freq}M', + '-s', '22050', + '-g', str(gain), + '-d', str(device), + ] + if ppm and str(ppm) != '0': + rtl_fm_cmd.extend(['-p', str(ppm)]) + if squelch and str(squelch) != '0': + rtl_fm_cmd.extend(['-l', str(squelch)]) + + # Build multimon-ng command + multimon_cmd = [multimon_path, '-t', 'raw', '-a'] + for proto in protocols: + if proto in ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']: + multimon_cmd.extend(['-a', proto]) + multimon_cmd.append('-') + + logger.info(f"Starting pager: {' '.join(rtl_fm_cmd)} | {' '.join(multimon_cmd)}") + + try: + # Start rtl_fm process + rtl_fm_proc = subprocess.Popen( + rtl_fm_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Pipe to multimon-ng + multimon_proc = subprocess.Popen( + multimon_cmd, + stdin=rtl_fm_proc.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + rtl_fm_proc.stdout.close() # Allow SIGPIPE + + # Store both processes + self.processes['pager'] = multimon_proc + self.processes['pager_rtl'] = rtl_fm_proc + + # Wait briefly to verify processes started successfully + time.sleep(0.5) + if rtl_fm_proc.poll() is not None: + stderr_output = rtl_fm_proc.stderr.read().decode('utf-8', errors='replace') + multimon_proc.terminate() + del self.processes['pager'] + del self.processes['pager_rtl'] + return {'status': 'error', 'message': f'rtl_fm failed to start: {stderr_output[:200]}'} + + # Start output reader + thread = threading.Thread( + target=self._pager_output_reader, + args=(multimon_proc,), + daemon=True + ) + thread.start() + self.output_threads['pager'] = thread + + return { + 'status': 'started', + 'mode': 'pager', + 'frequency': freq, + 'protocols': protocols, + 'gps_enabled': gps_manager.is_running + } + + except FileNotFoundError as e: + return {'status': 'error', 'message': str(e)} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def _pager_output_reader(self, proc: subprocess.Popen): + """Read and parse multimon-ng output for pager messages.""" + mode = 'pager' + stop_event = self.stop_events.get(mode) + + try: + for line in iter(proc.stdout.readline, b''): + if stop_event and stop_event.is_set(): + break + + line = line.decode('utf-8', errors='replace').strip() + if not line: + continue + + parsed = self._parse_pager_message(line) + if parsed: + parsed['received_at'] = datetime.now(timezone.utc).isoformat() + + gps_pos = gps_manager.position + if gps_pos: + parsed['agent_gps'] = gps_pos + + snapshots = self.data_snapshots.get(mode, []) + snapshots.append(parsed) + if len(snapshots) > 200: + snapshots = snapshots[-200:] + self.data_snapshots[mode] = snapshots + + logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}") + + except Exception as e: + logger.error(f"Pager reader error: {e}") + finally: + proc.wait() + if 'pager_rtl' in self.processes: + rtl_proc = self.processes['pager_rtl'] + if rtl_proc.poll() is None: + rtl_proc.terminate() + del self.processes['pager_rtl'] + logger.info("Pager reader stopped") + + def _parse_pager_message(self, line: str) -> dict | None: + """Parse multimon-ng output line for POCSAG/FLEX using Intercept's parser.""" + try: + # Use Intercept's existing pager parser + from routes.pager import parse_multimon_output + parsed = parse_multimon_output(line) + if parsed: + parsed['type'] = 'pager' + return parsed + return None + except ImportError: + # Fallback to inline parsing if import fails + import re + # POCSAG with message + match = re.match( + r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)', + line + ) + if match: + return { + 'type': 'pager', + 'protocol': match.group(1), + 'address': match.group(2), + 'function': match.group(3), + 'msg_type': match.group(4), + 'message': match.group(5).strip() or '[No Message]' + } + + # POCSAG address only (tone) + match = re.match( + r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$', + line + ) + if match: + return { + 'type': 'pager', + 'protocol': match.group(1), + 'address': match.group(2), + 'function': match.group(3), + 'msg_type': 'Tone', + 'message': '[Tone Only]' + } + + # FLEX format + match = re.match(r'FLEX[:\|]\s*(.+)', line) + if match: + return { + 'type': 'pager', + 'protocol': 'FLEX', + 'address': 'Unknown', + 'function': '', + 'msg_type': 'Unknown', + 'message': match.group(1).strip() + } + + return None + + # ------------------------------------------------------------------------- + # AIS MODE (AIS-catcher) + # ------------------------------------------------------------------------- + + def _start_ais(self, params: dict) -> dict: + """Start AIS vessel tracking using AIS-catcher.""" + gain = params.get('gain', '33') + device = params.get('device', '0') + bias_t = params.get('bias_t', False) + + # Find AIS-catcher + ais_catcher = self._find_ais_catcher() + if not ais_catcher: + return {'status': 'error', 'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher'} + + # Initialize vessel dict + if not hasattr(self, 'ais_vessels'): + self.ais_vessels = {} + self.ais_vessels.clear() + + # Build command - output JSON on TCP port 1234 + cmd = [ + ais_catcher, + '-d', str(device), + '-gr', f'TUNER={gain}', + '-o', '4', # JSON format + '-N', '1234', # TCP output on port 1234 + ] + + if bias_t: + cmd.extend(['-gr', 'BIASTEE=on']) + + logger.info(f"Starting AIS-catcher: {' '.join(cmd)}") + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True + ) + self.processes['ais'] = proc + + time.sleep(2) + if proc.poll() is not None: + stderr = proc.stderr.read().decode('utf-8', errors='ignore') + return {'status': 'error', 'message': f'AIS-catcher failed: {stderr[:200]}'} + + # Start TCP reader thread + thread = threading.Thread( + target=self._ais_tcp_reader, + args=(1234,), + daemon=True + ) + thread.start() + self.output_threads['ais'] = thread + + return { + 'status': 'started', + 'mode': 'ais', + 'tcp_port': 1234, + 'gps_enabled': gps_manager.is_running + } + + except FileNotFoundError: + return {'status': 'error', 'message': 'AIS-catcher not found'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def _find_ais_catcher(self) -> str | None: + """Find AIS-catcher binary.""" + for name in ['AIS-catcher', 'aiscatcher']: + path = self._get_tool_path(name) + if path: + return path + for path in ['/usr/local/bin/AIS-catcher', '/usr/bin/AIS-catcher', '/opt/homebrew/bin/AIS-catcher']: + if os.path.isfile(path) and os.access(path, os.X_OK): + return path + return None + + def _ais_tcp_reader(self, port: int): + """Read JSON vessel data from AIS-catcher TCP port.""" + mode = 'ais' + stop_event = self.stop_events.get(mode) + retry_count = 0 + + # Initialize vessel dict + if not hasattr(self, 'ais_vessels'): + self.ais_vessels = {} + + while not (stop_event and stop_event.is_set()): + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5.0) + sock.connect(('localhost', port)) + logger.info(f"Connected to AIS-catcher on port {port}") + retry_count = 0 + + buffer = "" + sock.settimeout(1.0) + + while not (stop_event and stop_event.is_set()): + try: + data = sock.recv(4096).decode('utf-8', errors='ignore') + if not data: + break + buffer += data + + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + self._parse_ais_json(line.strip()) + + except socket.timeout: + continue + + sock.close() + + except Exception as e: + retry_count += 1 + if retry_count >= 10: + logger.error("Max AIS retries reached") + break + time.sleep(2) + + logger.info("AIS TCP reader stopped") + + def _parse_ais_json(self, line: str): + """Parse AIS-catcher JSON output.""" + if not line: + return + + try: + msg = json.loads(line) + except json.JSONDecodeError: + return + + mmsi = msg.get('mmsi') + if not mmsi: + return + + mmsi = str(mmsi) + vessel = self.ais_vessels.get(mmsi) or {'mmsi': mmsi} + vessel['last_seen'] = datetime.now(timezone.utc).isoformat() + + # Position + lat = msg.get('latitude') or msg.get('lat') + lon = msg.get('longitude') or msg.get('lon') + if lat is not None and lon is not None: + try: + lat, lon = float(lat), float(lon) + if -90 <= lat <= 90 and -180 <= lon <= 180: + vessel['lat'] = lat + vessel['lon'] = lon + except (ValueError, TypeError): + pass + + # Speed and course + for field, max_val in [('speed', 102.3), ('course', 360)]: + if field in msg: + try: + val = float(msg[field]) + if val < max_val: + vessel[field] = round(val, 1) + except (ValueError, TypeError): + pass + + if 'heading' in msg: + try: + heading = int(msg['heading']) + if heading < 360: + vessel['heading'] = heading + except (ValueError, TypeError): + pass + + # Static data + for field in ['name', 'callsign', 'destination', 'shiptype', 'ship_type']: + if field in msg and msg[field]: + key = 'ship_type' if field == 'shiptype' else field + vessel[key] = str(msg[field]).strip() + + gps_pos = gps_manager.position + if gps_pos: + vessel['agent_gps'] = gps_pos + + self.ais_vessels[mmsi] = vessel + + # ------------------------------------------------------------------------- + # ACARS MODE (acarsdec) + # ------------------------------------------------------------------------- + + def _detect_acarsdec_fork(self, acarsdec_path: str) -> str: + """Detect which acarsdec fork is installed. + + Returns: + '--output' for f00b4r0 fork (DragonOS) + '-j' for TLeconte v4+ + '-o' for TLeconte v3.x + """ + try: + result = subprocess.run( + [acarsdec_path], + capture_output=True, + text=True, + timeout=5 + ) + output = result.stdout + result.stderr + + # f00b4r0 fork uses --output instead of -j/-o + if '--output' in output: + return '--output' + + # Parse version for TLeconte + import re + version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE) + if version_match: + major = int(version_match.group(1)) + return '-j' if major >= 4 else '-o' + except Exception: + pass + return '-j' # Default to TLeconte v4+ + + def _start_acars(self, params: dict) -> dict: + """Start ACARS decoding using acarsdec.""" + gain = params.get('gain', '40') + device = params.get('device', '0') + frequencies = params.get('frequencies', ['131.550', '130.025', '129.125', '131.525', '131.725']) + + acarsdec_path = self._get_tool_path('acarsdec') + if not acarsdec_path: + return {'status': 'error', 'message': 'acarsdec not found. Install acarsdec.'} + + # Detect fork and build appropriate command + fork_type = self._detect_acarsdec_fork(acarsdec_path) + cmd = [acarsdec_path] + + if fork_type == '--output': + # f00b4r0 fork (DragonOS): different syntax + cmd.extend(['--output', 'json:file']) # stdout + cmd.extend(['-g', str(gain)]) + cmd.extend(['-m', '256']) # 3.2 MS/s for wider bandwidth + cmd.extend(['--rtlsdr', str(device)]) + elif fork_type == '-j': + # TLeconte v4+ + cmd.extend(['-j', '-g', str(gain), '-r', str(device)]) + else: + # TLeconte v3.x + cmd.extend(['-o', '4', '-g', str(gain), '-r', str(device)]) + + cmd.extend(frequencies) + + logger.info(f"Starting acarsdec: {' '.join(cmd)}") + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.processes['acars'] = proc + + thread = threading.Thread( + target=self._acars_output_reader, + args=(proc,), + daemon=True + ) + thread.start() + self.output_threads['acars'] = thread + + # Wait briefly to verify process started successfully + time.sleep(0.5) + if proc.poll() is not None: + # Process already exited - likely SDR busy or other error + stderr_output = proc.stderr.read().decode('utf-8', errors='replace') + del self.processes['acars'] + return {'status': 'error', 'message': f'acarsdec failed to start: {stderr_output[:200]}'} + + return { + 'status': 'started', + 'mode': 'acars', + 'frequencies': frequencies, + 'gps_enabled': gps_manager.is_running + } + + except FileNotFoundError: + return {'status': 'error', 'message': 'acarsdec not found'} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def _acars_output_reader(self, proc: subprocess.Popen): + """Read acarsdec JSON output.""" + mode = 'acars' + stop_event = self.stop_events.get(mode) + + try: + for line in iter(proc.stdout.readline, b''): + if stop_event and stop_event.is_set(): + break + + line = line.decode('utf-8', errors='replace').strip() + if not line: + continue + + try: + msg = json.loads(line) + msg['type'] = 'acars' + msg['received_at'] = datetime.now(timezone.utc).isoformat() + + gps_pos = gps_manager.position + if gps_pos: + msg['agent_gps'] = gps_pos + + snapshots = self.data_snapshots.get(mode, []) + snapshots.append(msg) + if len(snapshots) > 100: + snapshots = snapshots[-100:] + self.data_snapshots[mode] = snapshots + + logger.debug(f"ACARS: {msg.get('tail', 'Unknown')}") + + except json.JSONDecodeError: + pass + + except Exception as e: + logger.error(f"ACARS reader error: {e}") + finally: + proc.wait() + logger.info("ACARS reader stopped") + + # ------------------------------------------------------------------------- + # APRS MODE (rtl_fm | direwolf) + # ------------------------------------------------------------------------- + + def _start_aprs(self, params: dict) -> dict: + """Start APRS decoding using rtl_fm | direwolf.""" + freq = params.get('frequency', '144.390') # North America APRS + gain = params.get('gain', '40') + device = params.get('device', '0') + ppm = params.get('ppm', '0') + + rtl_fm_path = self._get_tool_path('rtl_fm') + if not rtl_fm_path: + return {'status': 'error', 'message': 'rtl_fm not found'} + + direwolf_path = self._get_tool_path('direwolf') + multimon_path = self._get_tool_path('multimon-ng') + decoder_path = direwolf_path or multimon_path + + if not decoder_path: + return {'status': 'error', 'message': 'direwolf or multimon-ng not found'} + + # Initialize state + if not hasattr(self, 'aprs_stations'): + self.aprs_stations = {} + self.aprs_stations.clear() + + # Build rtl_fm command for APRS (22050 Hz for AFSK 1200 baud) + rtl_fm_cmd = [ + rtl_fm_path, + '-f', f'{freq}M', + '-s', '22050', + '-g', str(gain), + '-d', str(device), + '-E', 'dc', + '-A', 'fast', + ] + if ppm and str(ppm) != '0': + rtl_fm_cmd.extend(['-p', str(ppm)]) + + # Build decoder command + if direwolf_path: + dw_config = '/tmp/intercept_direwolf.conf' + try: + with open(dw_config, 'w') as f: + f.write("ADEVICE stdin null\nARATE 22050\nMODEM 1200\n") + except Exception as e: + return {'status': 'error', 'message': f'Failed to create direwolf config: {e}'} + decoder_cmd = [direwolf_path, '-c', dw_config, '-r', '22050', '-t', '0', '-'] + else: + decoder_cmd = [multimon_path, '-t', 'raw', '-a', 'AFSK1200', '-'] + + logger.info(f"Starting APRS: {' '.join(rtl_fm_cmd)} | {' '.join(decoder_cmd)}") + + try: + rtl_fm_proc = subprocess.Popen( + rtl_fm_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + decoder_proc = subprocess.Popen( + decoder_cmd, + stdin=rtl_fm_proc.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + rtl_fm_proc.stdout.close() + + self.processes['aprs'] = decoder_proc + self.processes['aprs_rtl'] = rtl_fm_proc + + # Wait briefly to verify processes started successfully + time.sleep(0.5) + if rtl_fm_proc.poll() is not None: + stderr_output = rtl_fm_proc.stderr.read().decode('utf-8', errors='replace') + decoder_proc.terminate() + del self.processes['aprs'] + del self.processes['aprs_rtl'] + return {'status': 'error', 'message': f'rtl_fm failed to start: {stderr_output[:200]}'} + + thread = threading.Thread( + target=self._aprs_output_reader, + args=(decoder_proc, direwolf_path is not None), + daemon=True + ) + thread.start() + self.output_threads['aprs'] = thread + + return { + 'status': 'started', + 'mode': 'aprs', + 'frequency': freq, + 'decoder': 'direwolf' if direwolf_path else 'multimon-ng', + 'gps_enabled': gps_manager.is_running + } + + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def _aprs_output_reader(self, proc: subprocess.Popen, is_direwolf: bool): + """Read and parse APRS packets.""" + mode = 'aprs' + stop_event = self.stop_events.get(mode) + + try: + for line in iter(proc.stdout.readline, b''): + if stop_event and stop_event.is_set(): + break + + line = line.decode('utf-8', errors='replace').strip() + if not line: + continue + + parsed = self._parse_aprs_packet(line) + if parsed: + parsed['received_at'] = datetime.now(timezone.utc).isoformat() + + gps_pos = gps_manager.position + if gps_pos: + parsed['agent_gps'] = gps_pos + + callsign = parsed.get('callsign') + if callsign: + self.aprs_stations[callsign] = parsed + + snapshots = self.data_snapshots.get(mode, []) + snapshots.append(parsed) + if len(snapshots) > 100: + snapshots = snapshots[-100:] + self.data_snapshots[mode] = snapshots + + logger.debug(f"APRS: {callsign}") + + except Exception as e: + logger.error(f"APRS reader error: {e}") + finally: + proc.wait() + if 'aprs_rtl' in self.processes: + rtl_proc = self.processes['aprs_rtl'] + if rtl_proc.poll() is None: + rtl_proc.terminate() + del self.processes['aprs_rtl'] + logger.info("APRS reader stopped") + + def _parse_aprs_packet(self, line: str) -> dict | None: + """Parse APRS packet from direwolf or multimon-ng.""" + match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line) + if not match: + return None + + callsign = match.group(1) + path = match.group(2) + data = match.group(3) + + packet = { + 'type': 'aprs', + 'callsign': callsign, + 'path': path, + 'raw': data, + } + + # Try to extract position + pos_match = re.search(r'[!=/@](\d{4}\.\d{2})([NS])[/\\](\d{5}\.\d{2})([EW])', data) + if pos_match: + lat = float(pos_match.group(1)[:2]) + float(pos_match.group(1)[2:]) / 60 + if pos_match.group(2) == 'S': + lat = -lat + lon = float(pos_match.group(3)[:3]) + float(pos_match.group(3)[3:]) / 60 + if pos_match.group(4) == 'W': + lon = -lon + packet['lat'] = round(lat, 6) + packet['lon'] = round(lon, 6) + + return packet + + # ------------------------------------------------------------------------- + # RTLAMR MODE (rtl_tcp + rtlamr) + # ------------------------------------------------------------------------- + + def _start_rtlamr(self, params: dict) -> dict: + """Start utility meter reading using rtl_tcp + rtlamr.""" + freq = params.get('frequency', '912.0') + device = params.get('device', '0') + gain = params.get('gain', '40') + msg_type = params.get('msgtype', 'scm') + filter_id = params.get('filterid') + + rtl_tcp_path = self._get_tool_path('rtl_tcp') + rtlamr_path = self._get_tool_path('rtlamr') + + if not rtl_tcp_path: + return {'status': 'error', 'message': 'rtl_tcp not found. Install rtl-sdr.'} + if not rtlamr_path: + return {'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'} + + # Start rtl_tcp server + rtl_tcp_cmd = [rtl_tcp_path, '-a', '127.0.0.1', '-p', '1234', '-d', str(device)] + if gain: + rtl_tcp_cmd.extend(['-g', str(gain)]) + + logger.info(f"Starting rtl_tcp: {' '.join(rtl_tcp_cmd)}") + + try: + rtl_tcp_proc = subprocess.Popen( + rtl_tcp_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.processes['rtlamr_tcp'] = rtl_tcp_proc + + time.sleep(2) + if rtl_tcp_proc.poll() is not None: + stderr = rtl_tcp_proc.stderr.read().decode('utf-8', errors='ignore') + return {'status': 'error', 'message': f'rtl_tcp failed: {stderr[:200]}'} + + # Build rtlamr command + rtlamr_cmd = [ + rtlamr_path, + '-server=127.0.0.1:1234', + f'-msgtype={msg_type}', + '-format=json', + f'-centerfreq={int(float(freq) * 1e6)}', + '-unique=true', + ] + if filter_id: + rtlamr_cmd.append(f'-filterid={filter_id}') + + logger.info(f"Starting rtlamr: {' '.join(rtlamr_cmd)}") + + rtlamr_proc = subprocess.Popen( + rtlamr_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.processes['rtlamr'] = rtlamr_proc + + thread = threading.Thread( + target=self._rtlamr_output_reader, + args=(rtlamr_proc,), + daemon=True + ) + thread.start() + self.output_threads['rtlamr'] = thread + + return { + 'status': 'started', + 'mode': 'rtlamr', + 'frequency': freq, + 'msgtype': msg_type, + 'gps_enabled': gps_manager.is_running + } + + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def _rtlamr_output_reader(self, proc: subprocess.Popen): + """Read rtlamr JSON output.""" + mode = 'rtlamr' + stop_event = self.stop_events.get(mode) + + try: + for line in iter(proc.stdout.readline, b''): + if stop_event and stop_event.is_set(): + break + + line = line.decode('utf-8', errors='replace').strip() + if not line: + continue + + try: + msg = json.loads(line) + msg['type'] = 'rtlamr' + msg['received_at'] = datetime.now(timezone.utc).isoformat() + + gps_pos = gps_manager.position + if gps_pos: + msg['agent_gps'] = gps_pos + + snapshots = self.data_snapshots.get(mode, []) + snapshots.append(msg) + if len(snapshots) > 100: + snapshots = snapshots[-100:] + self.data_snapshots[mode] = snapshots + + logger.debug(f"RTLAMR: meter {msg.get('Message', {}).get('ID', 'Unknown')}") + + except json.JSONDecodeError: + pass + + except Exception as e: + logger.error(f"RTLAMR reader error: {e}") + finally: + proc.wait() + if 'rtlamr_tcp' in self.processes: + tcp_proc = self.processes['rtlamr_tcp'] + if tcp_proc.poll() is None: + tcp_proc.terminate() + del self.processes['rtlamr_tcp'] + logger.info("RTLAMR reader stopped") + + # ------------------------------------------------------------------------- + # DSC MODE (rtl_fm | dsc-decoder) - Digital Selective Calling + # ------------------------------------------------------------------------- + + def _start_dsc(self, params: dict) -> dict: + """Start DSC (VHF Channel 70) decoding using Intercept's DSCDecoder.""" + device = params.get('device', '0') + gain = params.get('gain', '40') + ppm = params.get('ppm', '0') + freq = '156.525' # DSC Channel 70 + + rtl_fm_path = self._get_tool_path('rtl_fm') + if not rtl_fm_path: + return {'status': 'error', 'message': 'rtl_fm not found'} + + # Initialize DSC messages list + if not hasattr(self, 'dsc_messages'): + self.dsc_messages = [] + + # Build rtl_fm command for DSC (48kHz sample rate) + rtl_fm_cmd = [ + rtl_fm_path, + '-f', f'{freq}M', + '-s', '48000', + '-g', str(gain), + '-d', str(device), + ] + if ppm and str(ppm) != '0': + rtl_fm_cmd.extend(['-p', str(ppm)]) + + logger.info(f"Starting DSC: {' '.join(rtl_fm_cmd)}") + + try: + rtl_fm_proc = subprocess.Popen( + rtl_fm_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.processes['dsc'] = rtl_fm_proc + + # Wait briefly to verify process started successfully + time.sleep(0.5) + if rtl_fm_proc.poll() is not None: + stderr_output = rtl_fm_proc.stderr.read().decode('utf-8', errors='replace') + del self.processes['dsc'] + return {'status': 'error', 'message': f'rtl_fm failed to start: {stderr_output[:200]}'} + + # Start output reader thread using Intercept's DSCDecoder + thread = threading.Thread( + target=self._dsc_output_reader, + args=(rtl_fm_proc,), + daemon=True + ) + thread.start() + self.output_threads['dsc'] = thread + + return { + 'status': 'started', + 'mode': 'dsc', + 'frequency': freq, + 'channel': 70, + 'gps_enabled': gps_manager.is_running + } + + except Exception as e: + return {'status': 'error', 'message': str(e)} + + def _dsc_output_reader(self, proc: subprocess.Popen): + """Read rtl_fm audio and decode DSC using Intercept's DSCDecoder.""" + mode = 'dsc' + stop_event = self.stop_events.get(mode) + + try: + # Use Intercept's DSC decoder + from utils.dsc.decoder import DSCDecoder + decoder = DSCDecoder(sample_rate=48000) + logger.info("Using Intercept's DSCDecoder") + + chunk_size = 9600 # 0.1 seconds at 48kHz, 16-bit + + while not (stop_event and stop_event.is_set()): + audio_data = proc.stdout.read(chunk_size) + if not audio_data: + break + + for message in decoder.process_audio(audio_data): + message['received_at'] = datetime.now(timezone.utc).isoformat() + + gps_pos = gps_manager.position + if gps_pos: + message['agent_gps'] = gps_pos + + # Store message + self.dsc_messages.append(message) + if len(self.dsc_messages) > 100: + self.dsc_messages = self.dsc_messages[-100:] + + self.data_snapshots[mode] = self.dsc_messages.copy() + logger.info(f"DSC message: {message.get('category')} from {message.get('source_mmsi')}") + + except ImportError: + logger.warning("DSCDecoder not available (missing scipy/numpy)") + except Exception as e: + logger.error(f"DSC reader error: {e}") + finally: + proc.wait() + logger.info("DSC reader stopped") + + # ------------------------------------------------------------------------- + # TSCM MODE (Technical Surveillance Countermeasures) + # ------------------------------------------------------------------------- + + def _start_tscm(self, params: dict) -> dict: + """Start TSCM scanning - uses existing Intercept scanning functions.""" + # Initialize state + if not hasattr(self, 'tscm_baseline'): + self.tscm_baseline = {} + if not hasattr(self, 'tscm_anomalies'): + self.tscm_anomalies = [] + if not hasattr(self, 'tscm_rf_signals'): + self.tscm_rf_signals = [] + self.tscm_anomalies.clear() + + # Get params for what to scan + scan_wifi = params.get('wifi', True) + scan_bt = params.get('bluetooth', True) + scan_rf = params.get('rf', True) + wifi_interface = params.get('wifi_interface') or params.get('interface') + bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0') + sdr_device = params.get('sdr_device', params.get('device', 0)) + + # Get baseline_id for comparison (same as local mode) + baseline_id = params.get('baseline_id') + + started_scans = [] + + # Start the combined TSCM scanner thread using existing Intercept functions + thread = threading.Thread( + target=self._tscm_scanner_thread, + args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id), + daemon=True + ) + thread.start() + self.output_threads['tscm'] = thread + + if scan_wifi: + started_scans.append('wifi') + if scan_bt: + started_scans.append('bluetooth') + if scan_rf: + started_scans.append('rf') + + return { + 'status': 'started', + 'mode': 'tscm', + 'note': f'TSCM scanning {", ".join(started_scans) if started_scans else "using existing data"}', + 'gps_enabled': gps_manager.is_running, + 'scanning': started_scans + } + + def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool, + wifi_interface: str | None, bt_adapter: str, sdr_device: int, + baseline_id: int | None = None): + """Combined TSCM scanner using existing Intercept functions. + + NOTE: This matches local mode behavior exactly: + - If baseline_id provided, loads baseline and detects 'new_device' threats + - If no baseline, only 'anomaly' and 'hidden_camera' threats are detected + - Each new device seen during sweep is analyzed once + """ + logger.info("TSCM thread starting...") + mode = 'tscm' + stop_event = self.stop_events.get(mode) + + # Import existing Intercept TSCM functions + from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals + logger.info("TSCM imports successful") + + # Load baseline if specified (same as local mode) + baseline = None + if baseline_id and HAS_BASELINE_DB and get_tscm_baseline: + baseline = get_tscm_baseline(baseline_id) + if baseline: + logger.info(f"TSCM loaded baseline '{baseline.get('name')}' (ID: {baseline_id})") + else: + logger.warning(f"TSCM baseline ID {baseline_id} not found") + + # Initialize detector and correlation engine (same as local mode) + if HAS_TSCM_MODULES and ThreatDetector: + self._tscm_detector = ThreatDetector(baseline=baseline) + self._tscm_correlation = CorrelationEngine() if CorrelationEngine else None + if baseline: + logger.info("TSCM detector initialized with baseline - will detect 'new_device' threats") + else: + logger.info("TSCM detector initialized without baseline - only 'anomaly'/'hidden_camera' threats") + else: + self._tscm_detector = None + self._tscm_correlation = None + + # Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts) + seen_wifi = {} + seen_bt = {} + + last_rf_scan = 0 + rf_scan_interval = 30 + + while not (stop_event and stop_event.is_set()): + try: + current_time = time.time() + + # WiFi scan using Intercept's function (same as local mode) + if scan_wifi: + try: + wifi_networks = _scan_wifi_networks(wifi_interface or '') + for net in wifi_networks: + bssid = net.get('bssid', '').upper() + if bssid and bssid not in seen_wifi: + # First time seeing this device during sweep + seen_wifi[bssid] = net + + # Enrich with classification/scoring + enriched = dict(net) + # Ensure power/signal is numeric (scanner may return string) + if 'power' in enriched: + try: + enriched['power'] = int(enriched['power']) + except (ValueError, TypeError): + enriched['power'] = -100 + if 'signal' in enriched and enriched['signal'] is not None: + try: + enriched['signal'] = int(enriched['signal']) + except (ValueError, TypeError): + enriched['signal'] = -100 + + # Analyze for threats (same as local mode) + if self._tscm_detector: + threat = self._tscm_detector.analyze_wifi_device(enriched) + if threat: + self.tscm_anomalies.append(threat) + if len(self.tscm_anomalies) > 100: + self.tscm_anomalies = self.tscm_anomalies[-100:] + print(f"[TSCM] WiFi threat: {threat.get('threat_type')} - {threat.get('name')}", flush=True) + + classification = self._tscm_detector.classify_wifi_device(enriched) + enriched['is_new'] = not classification.get('in_baseline', False) + enriched['reasons'] = classification.get('reasons', []) + + if self._tscm_correlation: + profile = self._tscm_correlation.analyze_wifi_device(enriched) + enriched['classification'] = profile.risk_level.value + enriched['score'] = profile.total_score + enriched['indicators'] = [ + {'type': i.type.value, 'desc': i.description} + for i in profile.indicators + ] + enriched['recommended_action'] = profile.recommended_action + + self.wifi_networks[bssid] = enriched + except Exception as e: + logger.debug(f"WiFi scan error: {e}") + + # Bluetooth scan using Intercept's function (same as local mode) + if scan_bt: + try: + bt_devices = _scan_bluetooth_devices(bt_adapter, duration=5) + for dev in bt_devices: + mac = dev.get('mac', '').upper() + if mac and mac not in seen_bt: + # First time seeing this device during sweep + seen_bt[mac] = dev + + # Enrich with classification/scoring + enriched = dict(dev) + # Ensure rssi/signal is numeric (scanner may return string) + if 'rssi' in enriched and enriched['rssi'] is not None: + try: + enriched['rssi'] = int(enriched['rssi']) + except (ValueError, TypeError): + enriched['rssi'] = -100 + + # Analyze for threats (same as local mode) + if self._tscm_detector: + threat = self._tscm_detector.analyze_bt_device(enriched) + if threat: + self.tscm_anomalies.append(threat) + if len(self.tscm_anomalies) > 100: + self.tscm_anomalies = self.tscm_anomalies[-100:] + logger.info(f"TSCM BT threat: {threat.get('threat_type')} - {threat.get('name')}") + + classification = self._tscm_detector.classify_bt_device(enriched) + enriched['is_new'] = not classification.get('in_baseline', False) + enriched['reasons'] = classification.get('reasons', []) + + if self._tscm_correlation: + profile = self._tscm_correlation.analyze_bluetooth_device(enriched) + enriched['classification'] = profile.risk_level.value + enriched['score'] = profile.total_score + enriched['indicators'] = [ + {'type': i.type.value, 'desc': i.description} + for i in profile.indicators + ] + enriched['recommended_action'] = profile.recommended_action + + self.bluetooth_devices[mac] = enriched + except Exception as e: + logger.debug(f"Bluetooth scan error: {e}") + + # RF scan using Intercept's function (less frequently) + if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval: + try: + # Pass a stop check that uses our stop_event (not the module's _sweep_running) + agent_stop_check = lambda: stop_event and stop_event.is_set() + rf_signals = _scan_rf_signals(sdr_device, stop_check=agent_stop_check) + + # Analyze each RF signal like local mode does + analyzed_signals = [] + rf_threats = [] + for signal in rf_signals: + analyzed = dict(signal) + is_threat = False + + # Use detector to analyze for threats (same as local mode) + if hasattr(self, '_tscm_detector') and self._tscm_detector: + threat = self._tscm_detector.analyze_rf_signal(signal) + if threat: + rf_threats.append(threat) + is_threat = True + classification = self._tscm_detector.classify_rf_signal(signal) + analyzed['is_new'] = not classification.get('in_baseline', False) + analyzed['reasons'] = classification.get('reasons', []) + + # Use correlation engine for scoring (same as local mode) + if hasattr(self, '_tscm_correlation') and self._tscm_correlation: + profile = self._tscm_correlation.analyze_rf_signal(signal) + analyzed['classification'] = profile.risk_level.value + analyzed['score'] = profile.total_score + analyzed['indicators'] = [ + {'type': i.type.value, 'desc': i.description} + for i in profile.indicators + ] + + analyzed['is_threat'] = is_threat + analyzed_signals.append(analyzed) + + # Add RF threats to anomalies list + if rf_threats: + self.tscm_anomalies.extend(rf_threats) + if len(self.tscm_anomalies) > 100: + self.tscm_anomalies = self.tscm_anomalies[-100:] + for threat in rf_threats: + logger.info(f"TSCM RF threat: {threat.get('threat_type')} - {threat.get('identifier')}") + + self.tscm_rf_signals = analyzed_signals + logger.info(f"RF scan found {len(analyzed_signals)} signals") + last_rf_scan = current_time + except Exception as e: + logger.debug(f"RF scan error: {e}") + + # Sleep between scan cycles (same interval as local mode) + time.sleep(5) + + except Exception as e: + logger.error(f"TSCM scanner error: {e}") + time.sleep(5) + + logger.info("TSCM scanner stopped") + + # ------------------------------------------------------------------------- + # SATELLITE MODE (TLE-based pass prediction) + # ------------------------------------------------------------------------- + + def _start_satellite(self, params: dict) -> dict: + """Start satellite pass prediction - no SDR needed.""" + lat = params.get('lat', params.get('latitude')) + lon = params.get('lon', params.get('longitude')) + min_elevation = params.get('min_elevation', 10) + + if lat is None or lon is None: + gps_pos = gps_manager.position + if gps_pos: + lat = gps_pos.get('lat') + lon = gps_pos.get('lon') + + if lat is None or lon is None: + return {'status': 'error', 'message': 'Observer location required (lat/lon)'} + + thread = threading.Thread( + target=self._satellite_predictor, + args=(float(lat), float(lon), int(min_elevation)), + daemon=True + ) + thread.start() + self.output_threads['satellite'] = thread + + return { + 'status': 'started', + 'mode': 'satellite', + 'observer': {'lat': lat, 'lon': lon}, + 'min_elevation': min_elevation, + 'note': 'Satellite pass prediction - no SDR required' + } + + def _satellite_predictor(self, lat: float, lon: float, min_elevation: int): + """Calculate satellite passes using TLE data.""" + mode = 'satellite' + stop_event = self.stop_events.get(mode) + + try: + from skyfield.api import Topos, load + + stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle' + satellites = load.tle_file(stations_url) + + ts = load.timescale() + observer = Topos(latitude_degrees=lat, longitude_degrees=lon) + + logger.info(f"Satellite predictor: {len(satellites)} satellites loaded") + + while not (stop_event and stop_event.is_set()): + passes = [] + now = ts.now() + end = ts.utc(now.utc_datetime().year, now.utc_datetime().month, + now.utc_datetime().day + 1) + + for sat in satellites[:20]: + try: + t, events = sat.find_events(observer, now, end, altitude_degrees=min_elevation) + + for ti, event in zip(t, events): + if event == 0: # Rise + difference = sat - observer + topocentric = difference.at(ti) + alt, az, _ = topocentric.altaz() + passes.append({ + 'satellite': sat.name, + 'rise_time': ti.utc_iso(), + 'rise_azimuth': round(az.degrees, 1), + 'max_elevation': min_elevation, + }) + except Exception: + continue + + self.data_snapshots[mode] = passes[:50] + time.sleep(300) + + except ImportError: + logger.warning("skyfield not installed - satellite prediction unavailable") + self.data_snapshots[mode] = [{'error': 'skyfield not installed'}] + except Exception as e: + logger.error(f"Satellite predictor error: {e}") + + logger.info("Satellite predictor stopped") + + # ------------------------------------------------------------------------- + # LISTENING POST MODE (Spectrum scanner - signal detection only) + # ------------------------------------------------------------------------- + + def _start_listening_post(self, params: dict) -> dict: + """ + Start listening post / spectrum scanner. + + Note: Full FFT streaming isn't practical over HTTP agents. + Instead provides signal detection events and activity log. + """ + start_freq = params.get('start_freq', 88.0) + end_freq = params.get('end_freq', 108.0) + # Step is sent in kHz from frontend, convert to MHz + step_khz = params.get('step', 100) + step = step_khz / 1000.0 # Convert kHz to MHz + modulation = params.get('modulation', 'wfm') + squelch = params.get('squelch', 20) + device = params.get('device', '0') + gain = params.get('gain', '40') + dwell_time = params.get('dwell_time', 1.0) + + rtl_fm_path = self._get_tool_path('rtl_fm') + if not rtl_fm_path: + return {'status': 'error', 'message': 'rtl_fm not found'} + + # Quick SDR availability check - try to run rtl_fm briefly + test_proc = None + try: + test_proc = subprocess.Popen( + [rtl_fm_path, '-f', f'{start_freq}M', '-d', str(device), '-g', str(gain)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + time.sleep(0.5) + if test_proc.poll() is not None: + stderr = test_proc.stderr.read().decode('utf-8', errors='ignore') + return {'status': 'error', 'message': f'SDR not available: {stderr[:200]}'} + # SDR is available - terminate test process + test_proc.terminate() + try: + test_proc.wait(timeout=2) + except subprocess.TimeoutExpired: + test_proc.kill() + test_proc.wait(timeout=1) + except Exception as e: + # Ensure test process is killed on any error + if test_proc and test_proc.poll() is None: + test_proc.kill() + try: + test_proc.wait(timeout=1) + except Exception: + pass + return {'status': 'error', 'message': f'SDR check failed: {str(e)}'} + + # Initialize state + if not hasattr(self, 'listening_post_activity'): + self.listening_post_activity = [] + self.listening_post_activity.clear() + self.listening_post_current_freq = float(start_freq) + + thread = threading.Thread( + target=self._listening_post_scanner, + args=(float(start_freq), float(end_freq), float(step), + modulation, int(squelch), str(device), str(gain), float(dwell_time)), + daemon=True + ) + thread.start() + self.output_threads['listening_post'] = thread + + return { + 'status': 'started', + 'mode': 'listening_post', + 'start_freq': start_freq, + 'end_freq': end_freq, + 'step': step, + 'modulation': modulation, + 'dwell_time': dwell_time, + 'note': 'Provides signal detection events, not full FFT data', + 'gps_enabled': gps_manager.is_running + } + + def _listening_post_scanner(self, start_freq: float, end_freq: float, + step: float, modulation: str, squelch: int, + device: str, gain: str, dwell_time: float = 1.0): + """Scan frequency range and report signal detections.""" + import select + import os + import fcntl + + mode = 'listening_post' + stop_event = self.stop_events.get(mode) + + rtl_fm_path = self._get_tool_path('rtl_fm') + current_freq = start_freq + scan_direction = 1 + self.listening_post_freqs_scanned = 0 + + logger.info(f"Listening post scanner starting: {start_freq}-{end_freq} MHz, step {step}, dwell {dwell_time}s") + + while not (stop_event and stop_event.is_set()): + self.listening_post_current_freq = current_freq + + cmd = [ + rtl_fm_path, + '-f', f'{current_freq}M', + '-M', modulation, + '-s', '22050', + '-g', gain, + '-d', device, + '-l', str(squelch), + ] + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Set stdout to non-blocking + fd = proc.stdout.fileno() + flags = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + signal_detected = False + start_time = time.time() + + while time.time() - start_time < dwell_time: + if stop_event and stop_event.is_set(): + break + + # Use select for non-blocking read with timeout + ready, _, _ = select.select([proc.stdout], [], [], 0.1) + if ready: + try: + data = proc.stdout.read(2205) + if data and len(data) > 10: + # Simple signal detection via audio level + try: + samples = [int.from_bytes(data[i:i+2], 'little', signed=True) + for i in range(0, min(len(data)-1, 1000), 2)] + if samples: + rms = (sum(s*s for s in samples) / len(samples)) ** 0.5 + if rms > 500: + signal_detected = True + except Exception: + pass + except (IOError, BlockingIOError): + pass + + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=1) + + self.listening_post_freqs_scanned += 1 + + if signal_detected: + event = { + 'type': 'signal_found', + 'frequency': current_freq, + 'modulation': modulation, + 'detected_at': datetime.now(timezone.utc).isoformat() + } + + gps_pos = gps_manager.position + if gps_pos: + event['agent_gps'] = gps_pos + + self.listening_post_activity.append(event) + if len(self.listening_post_activity) > 500: + self.listening_post_activity = self.listening_post_activity[-500:] + + self.data_snapshots[mode] = self.listening_post_activity.copy() + logger.info(f"Listening post: signal at {current_freq} MHz") + + except Exception as e: + logger.debug(f"Scanner error at {current_freq}: {e}") + + # Move to next frequency + current_freq += step * scan_direction + if current_freq >= end_freq: + current_freq = end_freq + scan_direction = -1 + elif current_freq <= start_freq: + current_freq = start_freq + scan_direction = 1 + + time.sleep(0.1) + + logger.info("Listening post scanner stopped") + + +# Global mode manager +mode_manager = ModeManager() +_start_time = time.time() + + +# ============================================================================= +# Data Push Loop +# ============================================================================= + +class DataPushLoop(threading.Thread): + """Background thread that periodically pushes mode data to controller.""" + + def __init__(self, interval_seconds: float = 5.0): + super().__init__() + self.daemon = True + self.interval = interval_seconds + self.stop_event = threading.Event() + + def run(self): + """Main push loop.""" + logger.info(f"Data push loop started (interval: {self.interval}s)") + + while not self.stop_event.is_set(): + if push_client and push_client.running: + # Push data for all running modes + for mode in list(mode_manager.running_modes.keys()): + try: + data = mode_manager.get_mode_data(mode) + if data.get('data'): # Only push if there's data + push_client.enqueue( + scan_type=mode, + payload=data, + interface=None + ) + except Exception as e: + logger.warning(f"Failed to push {mode} data: {e}") + + # Wait for next interval + self.stop_event.wait(self.interval) + + logger.info("Data push loop stopped") + + def stop(self): + """Stop the push loop.""" + self.stop_event.set() + + +# Global push loop +data_push_loop: DataPushLoop | None = None + + +# ============================================================================= +# HTTP Request Handler +# ============================================================================= + +class InterceptAgentHandler(BaseHTTPRequestHandler): + """HTTP request handler for the agent API.""" + + # Disable default logging + def log_message(self, format, *args): + logger.debug(f"{self.client_address[0]} - {format % args}") + + def _check_ip_allowed(self) -> bool: + """Check if client IP is allowed.""" + if not config.allowed_ips: + return True + + client_ip = self.client_address[0] + return client_ip in config.allowed_ips + + def _send_json(self, data: dict, status: int = 200): + """Send JSON response.""" + body = json.dumps(data).encode('utf-8') + + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', len(body)) + if config.allow_cors: + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + self.wfile.write(body) + + def _send_error(self, message: str, status: int = 400): + """Send error response.""" + self._send_json({'error': message}, status) + + def _read_body(self) -> dict: + """Read and parse JSON body.""" + content_length = int(self.headers.get('Content-Length', 0)) + if content_length == 0: + return {} + + body = self.rfile.read(content_length) + try: + return json.loads(body.decode('utf-8')) + except json.JSONDecodeError: + return {} + + def _parse_path(self) -> tuple[str, dict]: + """Parse URL path and query parameters.""" + parsed = urlparse(self.path) + path = parsed.path.rstrip('/') + query = parse_qs(parsed.query) + # Flatten single-value query params + params = {k: v[0] if len(v) == 1 else v for k, v in query.items()} + return path, params + + def do_OPTIONS(self): + """Handle CORS preflight.""" + self.send_response(204) + if config.allow_cors: + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key') + self.end_headers() + + def do_GET(self): + """Handle GET requests.""" + if not self._check_ip_allowed(): + self._send_error('Forbidden', 403) + return + + path, params = self._parse_path() + + # Route handling + if path == '/capabilities': + self._send_json(mode_manager.detect_capabilities()) + + elif path == '/status': + self._send_json(mode_manager.get_status()) + + elif path == '/health': + self._send_json({'status': 'healthy', 'version': AGENT_VERSION}) + + elif path == '/gps': + gps_pos = gps_manager.position + self._send_json({ + 'available': gps_manager.is_running, + 'position': gps_pos, + }) + + elif path == '/config': + # Return non-sensitive config + cfg = config.to_dict() + if 'controller_api_key' in cfg: + del cfg['controller_api_key'] + self._send_json(cfg) + + elif path.startswith('/') and path.count('/') == 2: + # /{mode}/status or /{mode}/data + parts = path.split('/') + mode = parts[1] + action = parts[2] + + if action == 'status': + self._send_json(mode_manager.get_mode_status(mode)) + elif action == 'data': + self._send_json(mode_manager.get_mode_data(mode)) + else: + self._send_error('Not found', 404) + + else: + self._send_error('Not found', 404) + + def do_POST(self): + """Handle POST requests.""" + if not self._check_ip_allowed(): + self._send_error('Forbidden', 403) + return + + path, _ = self._parse_path() + body = self._read_body() + + if path == '/config': + # Update running config (limited fields) + if 'push_enabled' in body: + config.push_enabled = bool(body['push_enabled']) + if 'push_interval' in body: + config.push_interval = int(body['push_interval']) + self._send_json({'status': 'updated', 'config': config.to_dict()}) + + elif path.startswith('/') and path.count('/') == 2: + # /{mode}/start or /{mode}/stop + parts = path.split('/') + mode = parts[1] + action = parts[2] + + if action == 'start': + result = mode_manager.start_mode(mode, body) + # Accept both 'started' and 'success' as valid (quick scans return 'success') + status = 200 if result.get('status') in ('started', 'success') else 400 + self._send_json(result, status) + elif action == 'stop': + result = mode_manager.stop_mode(mode) + self._send_json(result) + else: + self._send_error('Not found', 404) + + else: + self._send_error('Not found', 404) + + +# ============================================================================= +# Threaded HTTP Server +# ============================================================================= + +class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): + """Multi-threaded HTTP server.""" + allow_reuse_address = True + daemon_threads = True + + +# ============================================================================= +# Main +# ============================================================================= + +def main(): + global config, push_client, _start_time + + parser = argparse.ArgumentParser( + description='INTERCEPT Agent - Remote signal intelligence node' + ) + parser.add_argument( + '--port', '-p', + type=int, + default=8020, + help='Port to listen on (default: 8020)' + ) + parser.add_argument( + '--config', '-c', + default='intercept_agent.cfg', + help='Configuration file (default: intercept_agent.cfg)' + ) + parser.add_argument( + '--name', '-n', + help='Agent name (overrides config file)' + ) + parser.add_argument( + '--controller', + help='Controller URL for push mode' + ) + parser.add_argument( + '--api-key', + help='API key for controller authentication' + ) + parser.add_argument( + '--allowed-ips', + help='Comma-separated list of allowed client IPs' + ) + parser.add_argument( + '--cors', + action='store_true', + help='Enable CORS headers' + ) + parser.add_argument( + '--debug', + action='store_true', + help='Enable debug logging' + ) + + args = parser.parse_args() + + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) + + # Load config file + config_path = args.config + if not os.path.isabs(config_path): + config_path = os.path.join(os.path.dirname(__file__), config_path) + config.load_from_file(config_path) + + # Override with command line args + if args.port: + config.port = args.port + if args.name: + config.name = args.name + if args.controller: + config.controller_url = args.controller.rstrip('/') + config.push_enabled = True + if args.api_key: + config.controller_api_key = args.api_key + if args.allowed_ips: + config.allowed_ips = [ip.strip() for ip in args.allowed_ips.split(',')] + if args.cors: + config.allow_cors = True + + _start_time = time.time() + + print("=" * 60) + print(" INTERCEPT AGENT") + print(" Remote Signal Intelligence Node") + print("=" * 60) + print() + print(f" Agent Name: {config.name}") + print(f" Port: {config.port}") + print(f" CORS: {'Enabled' if config.allow_cors else 'Disabled'}") + + # Start GPS + print() + print(" Initializing GPS...") + if gps_manager.start(): + print(" GPS: Connected to gpsd") + else: + print(" GPS: Not available (gpsd not running)") + if config.allowed_ips: + print(f" Allowed IPs: {', '.join(config.allowed_ips)}") + else: + print(" Allowed IPs: Any") + print() + + # Detect capabilities + caps = mode_manager.detect_capabilities() + print(" Available Modes:") + for mode, available in caps['modes'].items(): + status = "OK" if available else "N/A" + print(f" - {mode}: {status}") + print() + + if caps['devices']: + print(" Detected SDR Devices:") + for dev in caps['devices']: + print(f" - [{dev.get('index', '?')}] {dev.get('name', 'Unknown')}") + print() + + # Start push client if enabled + global data_push_loop + if config.push_enabled and config.controller_url: + print(f" Push Mode: Enabled -> {config.controller_url}") + push_client = ControllerPushClient(config) + push_client.start() + # Start data push loop + data_push_loop = DataPushLoop(interval_seconds=config.push_interval) + data_push_loop.start() + else: + print(" Push Mode: Disabled") + print() + + # Start HTTP server + server_address = ('', config.port) + httpd = ThreadedHTTPServer(server_address, InterceptAgentHandler) + + print(f" Listening on http://0.0.0.0:{config.port}") + print() + print(" Press Ctrl+C to stop") + print() + + # Handle shutdown + def signal_handler(sig, frame): + print("\nShutting down...") + # Stop all running modes + for mode in list(mode_manager.running_modes.keys()): + mode_manager.stop_mode(mode) + if data_push_loop: + data_push_loop.stop() + if push_client: + push_client.stop() + gps_manager.stop() + httpd.shutdown() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + if push_client: + push_client.stop() + + +if __name__ == '__main__': + main() diff --git a/routes/__init__.py b/routes/__init__.py index 894d16e..d499b7f 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -21,6 +21,7 @@ def register_blueprints(app): from .listening_post import listening_post_bp from .tscm import tscm_bp, init_tscm_state from .spy_stations import spy_stations_bp + from .controller import controller_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -41,6 +42,7 @@ def register_blueprints(app): app.register_blueprint(listening_post_bp) app.register_blueprint(tscm_bp) app.register_blueprint(spy_stations_bp) + app.register_blueprint(controller_bp) # Remote agent controller # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/acars.py b/routes/acars.py index 6d1c74e..1604b90 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -52,11 +52,13 @@ def find_acarsdec(): def get_acarsdec_json_flag(acarsdec_path: str) -> str: """Detect which JSON output flag acarsdec supports. - Version 4.0+ uses -j for JSON stdout. - Version 3.x uses -o 4 for JSON stdout. + Different forks use different flags: + - 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: - # 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( [acarsdec_path], capture_output=True, @@ -65,8 +67,15 @@ def get_acarsdec_json_flag(acarsdec_path: str) -> str: ) output = result.stdout + result.stderr - # Parse version from output like "Acarsdec v4.3.1" or "Acarsdec/acarsserv 3.7" 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) if version_match: major = int(version_match.group(1)) @@ -79,7 +88,7 @@ def get_acarsdec_json_flag(acarsdec_path: str) -> str: except Exception as 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' @@ -210,15 +219,20 @@ def start_acars() -> Response: acars_last_message_time = None # Build acarsdec command - # acarsdec -j -g -p -r ... - # Note: -j is JSON stdout (newer forks), -o 4 was the old syntax - # gain/ppm must come BEFORE -r + # Different forks have different syntax: + # - TLeconte v4+: acarsdec -j -g -p -r ... + # - TLeconte v3: acarsdec -o 4 -g -p -r ... + # - f00b4r0 (DragonOS): acarsdec --output json:file:- -g -p -r ... + # Note: gain/ppm must come BEFORE -r json_flag = get_acarsdec_json_flag(acarsdec_path) cmd = [acarsdec_path] - if json_flag == '-j': - cmd.append('-j') # JSON output (newer TLeconte fork) + if json_flag == '--output': + # 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: - 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) if gain and str(gain) != '0': @@ -228,8 +242,14 @@ def start_acars() -> Response: if ppm and str(ppm) != '0': cmd.extend(['-p', str(ppm)]) - # Add device and frequencies (-r takes device, remaining args are frequencies) - cmd.extend(['-r', str(device)]) + # Add device and frequencies + # f00b4r0 uses --rtlsdr , TLeconte uses -r + 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) logger.info(f"Starting ACARS decoder: {' '.join(cmd)}") diff --git a/routes/controller.py b/routes/controller.py new file mode 100644 index 0000000..d09ba31 --- /dev/null +++ b/routes/controller.py @@ -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/', 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/', 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/', 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//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//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///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///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///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///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/', 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 + ] + }) diff --git a/routes/tscm.py b/routes/tscm.py index 7957a73..190fbc2 100644 --- a/routes/tscm.py +++ b/routes/tscm.py @@ -944,7 +944,7 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]: 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). @@ -956,7 +956,16 @@ def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]: - 915 MHz: US ISM band - 1.2 GHz: 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 shutil 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 for start_freq, end_freq, bin_size, band_name in scan_bands: - if not _sweep_running: + if stop_check(): break logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)") diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css index be0c142..59bb36e 100644 --- a/static/css/adsb_dashboard.css +++ b/static/css/adsb_dashboard.css @@ -1710,6 +1710,12 @@ body { 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 { font-size: 11px; font-weight: 500; diff --git a/static/css/agents.css b/static/css/agents.css new file mode 100644 index 0000000..1d793f0 --- /dev/null +++ b/static/css/agents.css @@ -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; + } +} diff --git a/static/css/index.css b/static/css/index.css index 686aeb6..a319e12 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -1840,6 +1840,27 @@ header h1 .tagline { 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 { display: flex; align-items: center; diff --git a/static/js/core/agents.js b/static/js/core/agents.js new file mode 100644 index 0000000..cc4d76b --- /dev/null +++ b/static/js/core/agents.js @@ -0,0 +1,1102 @@ +/** + * Intercept - Agent Manager + * Handles remote agent selection and API routing + */ + +// ============== AGENT STATE ============== + +let agents = []; +let currentAgent = 'local'; +let agentEventSource = null; +let multiAgentMode = false; // Show combined results from all agents +let multiAgentPollInterval = null; +let agentRunningModes = []; // Track agent's running modes for conflict detection +let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents) +let healthCheckInterval = null; // Health monitoring interval +let agentHealthStatus = {}; // Cache of health status per agent ID + +// ============== AGENT HEALTH MONITORING ============== + +/** + * Start periodic health monitoring for all agents. + * Runs every 30 seconds to check agent health status. + */ +function startHealthMonitoring() { + // Don't start if already running + if (healthCheckInterval) return; + + // Initial check + checkAllAgentsHealth(); + + // Start periodic checks every 30 seconds + healthCheckInterval = setInterval(checkAllAgentsHealth, 30000); + console.log('[AgentManager] Health monitoring started (30s interval)'); +} + +/** + * Stop health monitoring. + */ +function stopHealthMonitoring() { + if (healthCheckInterval) { + clearInterval(healthCheckInterval); + healthCheckInterval = null; + console.log('[AgentManager] Health monitoring stopped'); + } +} + +/** + * Check health of all registered agents in one efficient call. + */ +async function checkAllAgentsHealth() { + if (agents.length === 0) return; + + try { + const response = await fetch('/controller/agents/health'); + const data = await response.json(); + + if (data.status === 'success' && data.agents) { + // Update health status cache and UI + data.agents.forEach(agentHealth => { + const previousHealth = agentHealthStatus[agentHealth.id]; + agentHealthStatus[agentHealth.id] = agentHealth; + + // Update agent in local list + const agent = agents.find(a => a.id === agentHealth.id); + if (agent) { + const wasHealthy = agent.healthy !== false; + agent.healthy = agentHealth.healthy; + agent.response_time_ms = agentHealth.response_time_ms; + agent.running_modes = agentHealth.running_modes || []; + agent.running_modes_detail = agentHealth.running_modes_detail || {}; + + // Log status change + if (wasHealthy !== agentHealth.healthy) { + console.log(`[AgentManager] ${agent.name} is now ${agentHealth.healthy ? 'ONLINE' : 'OFFLINE'}`); + + // Show notification for status change + if (!agentHealth.healthy && typeof showNotification === 'function') { + showNotification(`Agent "${agent.name}" went offline`, 'warning'); + } + } + } + }); + + // Update UI + updateAgentHealthUI(); + + // If current agent is selected, sync mode warnings + if (currentAgent !== 'local') { + const currentHealth = agentHealthStatus[currentAgent]; + if (currentHealth) { + agentRunningModes = currentHealth.running_modes || []; + agentRunningModesDetail = currentHealth.running_modes_detail || {}; + showAgentModeWarnings(agentRunningModes, agentRunningModesDetail); + } + } + } + } catch (error) { + console.error('[AgentManager] Health check failed:', error); + } +} + +/** + * Update the UI to reflect current health status. + */ +function updateAgentHealthUI() { + const selector = document.getElementById('agentSelect'); + if (!selector) return; + + // Update each option in selector with status and latency + agents.forEach(agent => { + const option = selector.querySelector(`option[value="${agent.id}"]`); + if (option) { + const health = agentHealthStatus[agent.id]; + const isHealthy = health ? health.healthy : agent.healthy !== false; + const status = isHealthy ? '●' : '○'; + const latency = health?.response_time_ms ? ` (${health.response_time_ms}ms)` : ''; + option.textContent = `${status} ${agent.name}${latency}`; + option.dataset.healthy = isHealthy; + } + }); + + // Update status display for current agent + updateAgentStatus(); + + // Update health panel if it exists + updateHealthPanel(); +} + +/** + * Update the optional health panel showing all agents. + */ +function updateHealthPanel() { + const panel = document.getElementById('agentHealthPanel'); + if (!panel) return; + + if (agents.length === 0) { + panel.innerHTML = '
No agents registered
'; + return; + } + + const html = agents.map(agent => { + const health = agentHealthStatus[agent.id]; + const isHealthy = health ? health.healthy : agent.healthy !== false; + const latency = health?.response_time_ms ? `${health.response_time_ms}ms` : '--'; + const modes = health?.running_modes?.length || 0; + const statusColor = isHealthy ? 'var(--accent-green)' : 'var(--accent-red)'; + const statusIcon = isHealthy ? '●' : '○'; + + return `
+ ${statusIcon} ${agent.name} + + ${latency} ${modes > 0 ? `| ${modes} mode${modes > 1 ? 's' : ''}` : ''} + +
`; + }).join(''); + + panel.innerHTML = html; +} + +// ============== AGENT LOADING ============== + +async function loadAgents() { + try { + const response = await fetch('/controller/agents'); + const data = await response.json(); + agents = data.agents || []; + updateAgentSelector(); + return agents; + } catch (error) { + console.error('Failed to load agents:', error); + agents = []; + updateAgentSelector(); + return []; + } +} + +function updateAgentSelector() { + const selector = document.getElementById('agentSelect'); + if (!selector) return; + + // Keep current selection if possible + const currentValue = selector.value; + + // Clear and rebuild options + selector.innerHTML = ''; + + agents.forEach(agent => { + const option = document.createElement('option'); + option.value = agent.id; + const status = agent.healthy !== false ? '●' : '○'; + option.textContent = `${status} ${agent.name}`; + option.dataset.baseUrl = agent.base_url; + option.dataset.healthy = agent.healthy !== false; + selector.appendChild(option); + }); + + // Restore selection if still valid + if (currentValue && selector.querySelector(`option[value="${currentValue}"]`)) { + selector.value = currentValue; + } + + updateAgentStatus(); + + // Show/hide "Show All Agents" options based on whether agents exist + updateShowAllAgentsVisibility(); +} + +/** + * Show or hide the "Show All Agents" checkboxes in mode panels. + */ +function updateShowAllAgentsVisibility() { + const hasAgents = agents.length > 0; + + // WiFi "Show All Agents" container + const wifiContainer = document.getElementById('wifiShowAllAgentsContainer'); + if (wifiContainer) { + wifiContainer.style.display = hasAgents ? 'block' : 'none'; + } + + // Bluetooth "Show All Agents" container + const btContainer = document.getElementById('btShowAllAgentsContainer'); + if (btContainer) { + btContainer.style.display = hasAgents ? 'block' : 'none'; + } +} + +function updateAgentStatus() { + const selector = document.getElementById('agentSelect'); + const statusDot = document.getElementById('agentStatusDot'); + const statusText = document.getElementById('agentStatusText'); + const latencyText = document.getElementById('agentLatencyText'); + + if (!selector || !statusDot) return; + + if (currentAgent === 'local') { + statusDot.className = 'agent-status-dot online'; + if (statusText) statusText.textContent = 'Local'; + if (latencyText) latencyText.textContent = ''; + } else { + const agent = agents.find(a => a.id == currentAgent); + if (agent) { + const health = agentHealthStatus[agent.id]; + const isOnline = health ? health.healthy : agent.healthy !== false; + statusDot.className = `agent-status-dot ${isOnline ? 'online' : 'offline'}`; + + if (statusText) { + statusText.textContent = isOnline ? 'Connected' : 'Offline'; + } + + // Show latency if available + if (latencyText) { + if (health?.response_time_ms) { + latencyText.textContent = `${health.response_time_ms}ms`; + } else { + latencyText.textContent = ''; + } + } + } + } +} + +// ============== RESPONSE UTILITIES ============== + +/** + * Unwrap agent response from controller proxy format. + * Controller returns: {status: 'success', result: {...agent response...}} + * This extracts the actual agent response. + * + * @param {Object} response - Response from fetch + * @param {boolean} isAgentMode - Whether this is an agent (vs local) request + * @returns {Object} - Unwrapped response + * @throws {Error} - If response indicates an error + */ +function unwrapAgentResponse(response, isAgentMode = false) { + if (!response) return null; + + // Check for error status first + if (response.status === 'error') { + throw new Error(response.message || response.error || 'Unknown error'); + } + + // If agent mode and has nested result, unwrap it + if (isAgentMode && response.status === 'success' && response.result !== undefined) { + const result = response.result; + + // Check if the nested result itself is an error + if (result.status === 'error') { + throw new Error(result.message || result.error || 'Agent operation failed'); + } + + return result; + } + + // Return as-is for local mode or already-unwrapped responses + return response; +} + +/** + * Check if currently operating in agent mode. + * @returns {boolean} + */ +function isAgentMode() { + return currentAgent !== 'local'; +} + +/** + * Get the current agent's name for display. + * @returns {string} + */ +function getCurrentAgentName() { + if (currentAgent === 'local') return 'Local'; + const agent = agents.find(a => a.id == currentAgent); + return agent ? agent.name : 'Unknown'; +} + +// ============== AGENT SELECTION ============== + +function selectAgent(agentId) { + currentAgent = agentId; + updateAgentStatus(); + + // Update device list based on selected agent + if (agentId === 'local') { + // Use local devices - call refreshDevices if it exists (defined in main page) + if (typeof refreshDevices === 'function') { + refreshDevices(); + } + // Refresh TSCM devices if function exists + if (typeof refreshTscmDevices === 'function') { + refreshTscmDevices(); + } + // Sync mode UI to local status (reset modes that aren't running locally) + syncLocalModeStates(); + // Notify WiFi mode of agent change + if (typeof WiFiMode !== 'undefined' && WiFiMode.handleAgentChange) { + WiFiMode.handleAgentChange(); + } + // Notify Bluetooth mode of agent change + if (typeof BluetoothMode !== 'undefined' && BluetoothMode.handleAgentChange) { + BluetoothMode.handleAgentChange(); + } + // Re-enable listen button for local mode + const listenBtn = document.getElementById('radioListenBtn'); + console.log('[agents.js] Enabling listen button, found:', listenBtn); + if (listenBtn) { + listenBtn.disabled = false; + listenBtn.style.opacity = '1'; + listenBtn.style.cursor = 'pointer'; + listenBtn.title = 'Listen to current frequency'; + } + if (typeof updateListenButtonState === 'function') { + updateListenButtonState(false); + } + console.log('Agent selected: Local'); + } else { + // Fetch devices from remote agent + refreshAgentDevices(agentId); + // Sync mode states with agent's actual running state + syncAgentModeStates(agentId); + // Refresh TSCM devices for agent + if (typeof refreshTscmDevices === 'function') { + refreshTscmDevices(); + } + // Notify WiFi mode of agent change + if (typeof WiFiMode !== 'undefined' && WiFiMode.handleAgentChange) { + WiFiMode.handleAgentChange(); + } + // Notify Bluetooth mode of agent change + if (typeof BluetoothMode !== 'undefined' && BluetoothMode.handleAgentChange) { + BluetoothMode.handleAgentChange(); + } + // Disable listen button for agent mode (audio can't stream over HTTP) + const listenBtn = document.getElementById('radioListenBtn'); + console.log('[agents.js] Disabling listen button, found:', listenBtn); + if (listenBtn) { + listenBtn.disabled = true; + listenBtn.style.opacity = '0.5'; + listenBtn.style.cursor = 'not-allowed'; + listenBtn.title = 'Audio listening not available for remote agents'; + } + if (typeof updateListenButtonState === 'function') { + updateListenButtonState(true); + } + const agentName = agents.find(a => a.id == agentId)?.name || 'Unknown'; + console.log(`Agent selected: ${agentName}`); + + // Show visual feedback + const statusText = document.getElementById('agentStatusText'); + if (statusText) { + statusText.textContent = `Loading ${agentName}...`; + setTimeout(() => updateAgentStatus(), 2000); + } + } +} + +/** + * Sync UI state with agent's actual running modes. + * This ensures UI reflects reality when agent was started externally + * or when user navigates away and back. + */ +async function syncAgentModeStates(agentId) { + try { + const response = await fetch(`/controller/agents/${agentId}/status`, { + credentials: 'same-origin' + }); + const data = await response.json(); + + if (data.status === 'success' && data.agent_status) { + agentRunningModes = data.agent_status.running_modes || []; + agentRunningModesDetail = data.agent_status.running_modes_detail || {}; + console.log(`Agent ${agentId} running modes:`, agentRunningModes); + console.log(`Agent ${agentId} mode details:`, agentRunningModesDetail); + + // IMPORTANT: Only sync UI if this agent is currently selected + // Otherwise we'd start streams for an agent the user hasn't selected + const isSelectedAgent = currentAgent == agentId; // Use == for string/number comparison + console.log(`Agent ${agentId} is selected: ${isSelectedAgent} (currentAgent=${currentAgent})`); + + if (isSelectedAgent) { + // Update UI for each mode based on agent state + agentRunningModes.forEach(mode => { + syncModeUI(mode, true, agentId); + }); + + // Also check modes that might need to be marked as stopped + const allModes = ['sensor', 'pager', 'adsb', 'wifi', 'bluetooth', 'ais', 'dsc', 'acars', 'aprs', 'rtlamr', 'tscm', 'satellite', 'listening_post']; + allModes.forEach(mode => { + if (!agentRunningModes.includes(mode)) { + syncModeUI(mode, false, agentId); + } + }); + } + + // Show warning if SDR modes are running (always show, regardless of selection) + showAgentModeWarnings(agentRunningModes, agentRunningModesDetail); + } + } catch (error) { + console.error('Failed to sync agent mode states:', error); + } +} + +/** + * Sync UI state with local mode statuses. + * Called when switching back to local to ensure UI reflects local state. + */ +async function syncLocalModeStates() { + console.log('[AgentManager] Syncing local mode states...'); + + // Check each mode's local status endpoint + const modeChecks = [ + { mode: 'pager', endpoint: '/status', runningKey: 'running' }, + { mode: 'sensor', endpoint: '/sensor/status', runningKey: 'running' }, + { mode: 'adsb', endpoint: '/adsb/status', runningKey: 'running' }, + { mode: 'tscm', endpoint: '/tscm/status', runningKey: 'running' }, + ]; + + for (const check of modeChecks) { + try { + const response = await fetch(check.endpoint); + if (response.ok) { + const data = await response.json(); + const isRunning = data[check.runningKey] || false; + syncModeUI(check.mode, isRunning, null); + } else { + // Endpoint not available or error - assume not running + syncModeUI(check.mode, false, null); + } + } catch (error) { + // Network error or endpoint doesn't exist - assume not running + syncModeUI(check.mode, false, null); + } + } + + // Clear agent mode warnings when switching to local + const warning = document.getElementById('agentModeWarning'); + if (warning) { + warning.style.display = 'none'; + } +} + +/** + * Show warnings about running modes that may cause conflicts. + * @param {string[]} runningModes - List of running mode names + * @param {Object} modesDetail - Detail info including device per mode + */ +function showAgentModeWarnings(runningModes, modesDetail = {}) { + // SDR modes that can't run simultaneously on same device + const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc']; + const runningSdrModes = runningModes.filter(m => sdrModes.includes(m)); + + let warning = document.getElementById('agentModeWarning'); + + if (runningSdrModes.length > 0) { + if (!warning) { + // Create warning element if it doesn't exist + const agentSection = document.getElementById('agentSection'); + if (agentSection) { + warning = document.createElement('div'); + warning.id = 'agentModeWarning'; + warning.style.cssText = 'color: #f0ad4e; font-size: 10px; padding: 4px 8px; background: rgba(240,173,78,0.1); border-radius: 4px; margin-top: 4px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;'; + agentSection.appendChild(warning); + } + } + if (warning) { + // Build mode buttons with device info + const modeButtons = runningSdrModes.map(m => { + const detail = modesDetail[m] || {}; + const deviceNum = detail.device !== undefined ? detail.device : '?'; + return ``; + }).join(' '); + warning.innerHTML = `⚠️ Running: ${modeButtons} `; + warning.style.display = 'flex'; + } + } else if (warning) { + warning.style.display = 'none'; + } +} + +/** + * Stop a mode on the agent and refresh state. + */ +async function stopAgentModeWithRefresh(mode) { + if (currentAgent === 'local') return; + + try { + const response = await fetch(`/controller/agents/${currentAgent}/${mode}/stop`, { + method: 'POST', + credentials: 'same-origin' + }); + const data = await response.json(); + console.log(`Stop ${mode} response:`, data); + + // Refresh agent state to update UI + await refreshAgentState(); + } catch (error) { + console.error(`Failed to stop ${mode} on agent:`, error); + alert(`Failed to stop ${mode}: ${error.message}`); + } +} + +/** + * Refresh agent state from server. + */ +async function refreshAgentState() { + if (currentAgent === 'local') return; + + console.log('Refreshing agent state...'); + await syncAgentModeStates(currentAgent); +} + +/** + * Check if a mode requires audio streaming (not supported via agents). + * @param {string} mode - Mode name + * @returns {boolean} - True if mode requires audio + */ +function isAudioMode(mode) { + const audioModes = ['airband', 'listening_post']; + return audioModes.includes(mode); +} + +/** + * Get the IP/hostname from an agent's base URL. + * @param {number|string} agentId - Agent ID + * @returns {string|null} - Hostname or null + */ +function getAgentHost(agentId) { + const agent = agents.find(a => a.id == agentId); + if (!agent || !agent.base_url) return null; + try { + const url = new URL(agent.base_url); + return url.hostname; + } catch (e) { + return null; + } +} + +/** + * Check if trying to start an audio mode on a remote agent. + * Offers rtl_tcp option instead of just blocking. + * @param {string} modeToStart - Mode to start + * @returns {boolean} - True if OK to proceed + */ +function checkAgentAudioMode(modeToStart) { + if (currentAgent === 'local') return true; + + if (isAudioMode(modeToStart)) { + const agentHost = getAgentHost(currentAgent); + const agentName = agents.find(a => a.id == currentAgent)?.name || 'remote agent'; + + alert( + `Audio streaming is not supported via remote agents.\n\n` + + `"${modeToStart}" requires real-time audio.\n\n` + + `To use audio from a remote SDR:\n\n` + + `1. On the agent (${agentName}):\n` + + ` Run: rtl_tcp -a 0.0.0.0\n\n` + + `2. On the Main Dashboard (/):\n` + + ` - Select "Local" mode\n` + + ` - Check "Use Remote SDR (rtl_tcp)"\n` + + ` - Enter host: ${agentHost || '[agent IP]'}\n` + + ` - Port: 1234\n\n` + + `Note: rtl_tcp config is on the Main Dashboard,\n` + + `not on specialized dashboards like ADS-B/AIS.` + ); + + return false; // Don't proceed with agent mode + } + return true; +} + +/** + * Check if trying to start a mode that conflicts with running modes. + * Returns true if OK to proceed, false if conflict exists. + * @param {string} modeToStart - Mode to start + * @param {number} deviceToUse - Device index to use (optional, for smarter conflict detection) + */ +function checkAgentModeConflict(modeToStart, deviceToUse = null) { + if (currentAgent === 'local') return true; // No conflict checking for local + + // First check if this is an audio mode + if (!checkAgentAudioMode(modeToStart)) { + return false; + } + + const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc']; + + // If we're trying to start an SDR mode + if (sdrModes.includes(modeToStart)) { + // Check for conflicts - if device is specified, only check that device + let conflictingModes = []; + + if (deviceToUse !== null && Object.keys(agentRunningModesDetail).length > 0) { + // Smart conflict detection: only flag modes using the same device + conflictingModes = agentRunningModes.filter(m => { + if (!sdrModes.includes(m) || m === modeToStart) return false; + const detail = agentRunningModesDetail[m]; + return detail && detail.device === deviceToUse; + }); + } else { + // Fallback: warn about all running SDR modes + conflictingModes = agentRunningModes.filter(m => + sdrModes.includes(m) && m !== modeToStart + ); + } + + if (conflictingModes.length > 0) { + const modeList = conflictingModes.map(m => { + const detail = agentRunningModesDetail[m]; + return detail ? `${m} (SDR ${detail.device})` : m; + }).join(', '); + + const proceed = confirm( + `The agent's SDR device is currently running: ${modeList}\n\n` + + `Starting ${modeToStart} on the same device will fail.\n\n` + + `Do you want to stop the conflicting mode(s) first?` + ); + + if (proceed) { + // Stop conflicting modes + conflictingModes.forEach(mode => { + stopAgentModeQuiet(mode); + }); + return true; + } + return false; + } + } + + return true; +} + +/** + * Stop a mode on the current agent (without UI feedback). + */ +async function stopAgentModeQuiet(mode) { + if (currentAgent === 'local') return; + + try { + await fetch(`/controller/agents/${currentAgent}/${mode}/stop`, { + method: 'POST', + credentials: 'same-origin' + }); + console.log(`Stopped ${mode} on agent ${currentAgent}`); + // Remove from running modes + agentRunningModes = agentRunningModes.filter(m => m !== mode); + syncModeUI(mode, false); + showAgentModeWarnings(agentRunningModes); + } catch (error) { + console.error(`Failed to stop ${mode} on agent:`, error); + } +} + +/** + * Update UI elements for a specific mode based on running state. + * @param {string} mode - Mode name (adsb, wifi, etc.) + * @param {boolean} isRunning - Whether the mode is running + * @param {string|number|null} agentId - Agent ID if running on agent, null for local + */ +function syncModeUI(mode, isRunning, agentId = null) { + // Map mode names to UI setter functions (if they exist) + const uiSetters = { + 'sensor': 'setSensorRunning', + 'pager': 'setPagerRunning', + 'adsb': 'setADSBRunning', + 'wifi': 'setWiFiRunning', + 'bluetooth': 'setBluetoothRunning', + 'acars': 'setAcarsRunning', + 'listening_post': 'setListeningPostRunning' + }; + + const setterName = uiSetters[mode]; + if (setterName && typeof window[setterName] === 'function') { + // Pass agent ID as source for functions that support it (like setADSBRunning) + window[setterName](isRunning, agentId); + console.log(`Synced ${mode} UI state: ${isRunning ? 'running' : 'stopped'} (agent: ${agentId || 'local'})`); + } +} + +async function refreshAgentDevices(agentId) { + console.log(`Refreshing devices for agent ${agentId}...`); + try { + const response = await fetch(`/controller/agents/${agentId}?refresh=true`, { + credentials: 'same-origin' + }); + const data = await response.json(); + console.log('Agent data received:', data); + + if (data.agent && data.agent.interfaces) { + // Agent stores SDR devices in interfaces.sdr_devices (matching local mode) + const devices = data.agent.interfaces.sdr_devices || data.agent.interfaces.devices || []; + console.log(`Found ${devices.length} devices on agent`); + + // Auto-select SDR type if devices found + if (devices.length > 0) { + const firstType = devices[0].sdr_type || 'rtlsdr'; + const sdrTypeSelect = document.getElementById('sdrTypeSelect'); + if (sdrTypeSelect) { + sdrTypeSelect.value = firstType; + } + } + + // Directly populate device dropdown for agent mode + // (Don't use onSDRTypeChanged since currentDeviceList is template-scoped) + populateDeviceSelect(devices); + } else { + console.warn('No interfaces found in agent data:', data); + // Show empty devices + populateDeviceSelect([]); + } + } catch (error) { + console.error('Failed to refresh agent devices:', error); + populateDeviceSelect([]); + } +} + +function populateDeviceSelect(devices) { + const select = document.getElementById('deviceSelect'); + if (!select) return; + + select.innerHTML = ''; + + if (devices.length === 0) { + const option = document.createElement('option'); + option.value = '0'; + option.textContent = 'No devices found'; + select.appendChild(option); + } else { + devices.forEach(device => { + const option = document.createElement('option'); + option.value = device.index; + option.dataset.sdrType = device.sdr_type || 'rtlsdr'; + option.textContent = `${device.index}: ${device.name}`; + select.appendChild(option); + }); + } +} + +// ============== API ROUTING ============== + +/** + * Route an API call to local or remote agent based on current selection. + * @param {string} localPath - Local API path (e.g., '/sensor/start') + * @param {Object} options - Fetch options + * @returns {Promise} + */ +async function agentFetch(localPath, options = {}) { + if (currentAgent === 'local') { + return fetch(localPath, options); + } + + // Route through controller proxy + const proxyPath = `/controller/agents/${currentAgent}${localPath}`; + return fetch(proxyPath, options); +} + +/** + * Start a mode on the selected agent. + * @param {string} mode - Mode name (pager, sensor, adsb, wifi, etc.) + * @param {Object} params - Mode parameters + * @returns {Promise} + */ +async function agentStartMode(mode, params = {}) { + const path = `/${mode}/start`; + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + }; + + try { + const response = await agentFetch(path, options); + return await response.json(); + } catch (error) { + console.error(`Failed to start ${mode} on agent:`, error); + throw error; + } +} + +/** + * Stop a mode on the selected agent. + * @param {string} mode - Mode name + * @returns {Promise} + */ +async function agentStopMode(mode) { + const path = `/${mode}/stop`; + const options = { method: 'POST' }; + + try { + const response = await agentFetch(path, options); + return await response.json(); + } catch (error) { + console.error(`Failed to stop ${mode} on agent:`, error); + throw error; + } +} + +/** + * Get data from a mode on the selected agent. + * @param {string} mode - Mode name + * @returns {Promise} + */ +async function agentGetData(mode) { + const path = `/${mode}/data`; + + try { + const response = await agentFetch(path); + return await response.json(); + } catch (error) { + console.error(`Failed to get ${mode} data from agent:`, error); + throw error; + } +} + +// ============== SSE STREAM ============== + +/** + * Connect to SSE stream (local or multi-agent). + * @param {string} mode - Mode name for the stream + * @param {function} onMessage - Callback for messages + * @returns {EventSource} + */ +function connectAgentStream(mode, onMessage) { + // Close existing connection + if (agentEventSource) { + agentEventSource.close(); + } + + let streamUrl; + if (currentAgent === 'local') { + streamUrl = `/${mode}/stream`; + } else { + // For remote agents, we could either: + // 1. Use the multi-agent stream: /controller/stream/all + // 2. Or proxy through controller (not implemented yet) + // For now, use multi-agent stream which includes agent_name tagging + streamUrl = '/controller/stream/all'; + } + + agentEventSource = new EventSource(streamUrl); + + agentEventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // If using multi-agent stream, filter by current agent if needed + if (streamUrl === '/controller/stream/all' && currentAgent !== 'local') { + const agent = agents.find(a => a.id == currentAgent); + if (agent && data.agent_name && data.agent_name !== agent.name) { + return; // Skip messages from other agents + } + } + + onMessage(data); + } catch (e) { + console.error('Error parsing SSE message:', e); + } + }; + + agentEventSource.onerror = (error) => { + console.error('SSE connection error:', error); + }; + + return agentEventSource; +} + +function disconnectAgentStream() { + if (agentEventSource) { + agentEventSource.close(); + agentEventSource = null; + } +} + +// ============== INITIALIZATION ============== + +function initAgentManager() { + // Load agents on page load + loadAgents().then(() => { + // Start health monitoring after agents are loaded + if (agents.length > 0) { + startHealthMonitoring(); + } + }); + + // Set up agent selector change handler + const selector = document.getElementById('agentSelect'); + if (selector) { + selector.addEventListener('change', (e) => { + selectAgent(e.target.value); + }); + } + + // Refresh agent list periodically (less often since health monitor is active) + setInterval(async () => { + await loadAgents(); + // Start health monitoring if we now have agents + if (agents.length > 0 && !healthCheckInterval) { + startHealthMonitoring(); + } + }, 60000); // Refresh list every 60s (health checks every 30s) +} + +// ============== MULTI-AGENT MODE ============== + +/** + * Toggle multi-agent mode to show combined results from all agents. + */ +function toggleMultiAgentMode() { + const checkbox = document.getElementById('showAllAgents'); + multiAgentMode = checkbox ? checkbox.checked : false; + + const selector = document.getElementById('agentSelect'); + const statusText = document.getElementById('agentStatusText'); + + if (multiAgentMode) { + // Disable individual agent selection + if (selector) selector.disabled = true; + if (statusText) statusText.textContent = 'All Agents'; + + // Connect to multi-agent stream + connectMultiAgentStream(); + + console.log('Multi-agent mode enabled - showing all agents'); + } else { + // Re-enable individual selection + if (selector) selector.disabled = false; + updateAgentStatus(); + + // Disconnect multi-agent stream + disconnectMultiAgentStream(); + + console.log('Multi-agent mode disabled'); + } +} + +/** + * Connect to the combined multi-agent SSE stream. + */ +function connectMultiAgentStream() { + disconnectMultiAgentStream(); + + agentEventSource = new EventSource('/controller/stream/all'); + + agentEventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // Skip keepalive messages + if (data.type === 'keepalive') return; + + // Route to appropriate handler based on scan_type + handleMultiAgentData(data); + } catch (e) { + console.error('Error parsing multi-agent SSE:', e); + } + }; + + agentEventSource.onerror = (error) => { + console.error('Multi-agent SSE error:', error); + }; +} + +function disconnectMultiAgentStream() { + if (agentEventSource) { + agentEventSource.close(); + agentEventSource = null; + } + if (multiAgentPollInterval) { + clearInterval(multiAgentPollInterval); + multiAgentPollInterval = null; + } +} + +/** + * Handle data from multi-agent stream and route to display. + */ +function handleMultiAgentData(data) { + const agentName = data.agent_name || 'Unknown'; + const scanType = data.scan_type; + const payload = data.payload; + + // Add agent badge to the data for display + if (payload) { + payload._agent = agentName; + } + + // Route based on scan type + switch (scanType) { + case 'sensor': + if (payload && payload.sensors) { + payload.sensors.forEach(sensor => { + sensor._agent = agentName; + if (typeof displaySensorMessage === 'function') { + displaySensorMessage(sensor); + } + }); + } + break; + + case 'pager': + if (payload && payload.messages) { + payload.messages.forEach(msg => { + msg._agent = agentName; + // Display pager message if handler exists + if (typeof addPagerMessage === 'function') { + addPagerMessage(msg); + } + }); + } + break; + + case 'adsb': + if (payload && payload.aircraft) { + Object.values(payload.aircraft).forEach(ac => { + ac._agent = agentName; + // Update aircraft display if handler exists + if (typeof updateAircraft === 'function') { + updateAircraft(ac); + } + }); + } + break; + + case 'wifi': + // WiFi mode handles its own multi-agent stream processing + // This is a fallback for legacy display or when WiFi mode isn't active + if (payload && payload.networks) { + Object.values(payload.networks).forEach(net => { + net._agent = agentName; + // Use legacy display if available + if (typeof handleWifiNetworkImmediate === 'function') { + handleWifiNetworkImmediate(net); + } + }); + } + if (payload && payload.clients) { + Object.values(payload.clients).forEach(client => { + client._agent = agentName; + if (typeof handleWifiClientImmediate === 'function') { + handleWifiClientImmediate(client); + } + }); + } + break; + + case 'bluetooth': + if (payload && payload.devices) { + Object.values(payload.devices).forEach(device => { + device._agent = agentName; + // Update Bluetooth display if handler exists + if (typeof addBluetoothDevice === 'function') { + addBluetoothDevice(device); + } + }); + } + break; + + default: + console.log(`Multi-agent data from ${agentName}: ${scanType}`, payload); + } +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', initAgentManager); diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index be7e4f4..2972c0c 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -9,6 +9,7 @@ const BluetoothMode = (function() { // State let isScanning = false; let eventSource = null; + let agentPollTimer = null; // Polling fallback for agent mode let devices = new Map(); let baselineSet = false; let baselineCount = 0; @@ -36,6 +37,47 @@ const BluetoothMode = (function() { // Device list filter 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 */ @@ -526,8 +568,37 @@ const BluetoothMode = (function() { */ async function checkCapabilities() { try { - const response = await fetch('/api/bluetooth/capabilities'); - const data = await response.json(); + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + 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) { showCapabilityWarning(['Bluetooth not available on this system']); @@ -579,10 +650,17 @@ const BluetoothMode = (function() { async function checkScanStatus() { try { - const response = await fetch('/api/bluetooth/scan/status'); - const data = await response.json(); + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + 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); startEventStream(); } @@ -599,32 +677,60 @@ const BluetoothMode = (function() { } async function startScan() { + // Check for agent mode conflicts + if (!checkAgentConflicts()) { + return; + } + const adapter = adapterSelect?.value || ''; const mode = scanModeSelect?.value || 'auto'; const transport = transportSelect?.value || 'auto'; const duration = parseInt(durationInput?.value || '0', 10); const minRssi = parseInt(minRssiInput?.value || '-100', 10); + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + try { - const 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 - }) - }); + let response; + if (isAgentMode) { + // Route through agent proxy + response = await fetch(`/controller/agents/${currentAgent}/bluetooth/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 + }) + }); + } 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(); - 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); startEventStream(); + } else if (scanResult.status === 'error') { + showErrorMessage(scanResult.message || 'Failed to start scan'); } else { - showErrorMessage(data.message || 'Failed to start scan'); + showErrorMessage(scanResult.message || 'Failed to start scan'); } } catch (err) { @@ -634,8 +740,14 @@ const BluetoothMode = (function() { } async function stopScan() { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + 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); stopEventStream(); } catch (err) { @@ -680,27 +792,84 @@ const BluetoothMode = (function() { function startEventStream() { 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) => { - try { - const device = JSON.parse(e.data); - handleDeviceUpdate(device); - } catch (err) { - console.error('Failed to parse device update:', err); - } - }); + if (isAgentMode) { + // Use multi-agent stream for remote agents + streamUrl = '/controller/stream/all'; + console.log('[BT] Starting multi-agent event stream...'); + } else { + streamUrl = '/api/bluetooth/stream'; + console.log('[BT] Starting local event stream...'); + } - eventSource.addEventListener('scan_started', (e) => { - setScanning(true); - }); + eventSource = new EventSource(streamUrl); - eventSource.addEventListener('scan_stopped', (e) => { - setScanning(false); - }); + if (isAgentMode) { + // 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 = () => { 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 = 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) { @@ -876,6 +1093,7 @@ const BluetoothMode = (function() { const trackerType = device.tracker_type; const trackerConfidence = device.tracker_confidence; const riskScore = device.risk_score || 0; + const agentName = device._agent || 'Local'; // Calculate RSSI bar width (0-100%) // RSSI typically ranges from -100 (weak) to -30 (very strong) @@ -929,6 +1147,10 @@ const BluetoothMode = (function() { let secondaryParts = [addr]; if (mfr) secondaryParts.push(mfr); secondaryParts.push('Seen ' + seenCount + '×'); + // Add agent name if not Local + if (agentName !== 'Local') { + secondaryParts.push('' + escapeHtml(agentName) + ''); + } const secondaryInfo = secondaryParts.join(' · '); // Row border color - highlight trackers in red/orange @@ -1019,6 +1241,112 @@ const BluetoothMode = (function() { function showErrorMessage(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 @@ -1033,8 +1361,16 @@ const BluetoothMode = (function() { selectDevice, clearSelection, copyAddress, + + // Agent handling + handleAgentChange, + clearData, + toggleShowAllAgents, + + // Getters getDevices: () => Array.from(devices.values()), - isScanning: () => isScanning + isScanning: () => isScanning, + isShowAllAgents: () => showAllAgentsMode }; })(); diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index ee9b89c..196594d 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -42,6 +42,10 @@ let recentSignalHits = new Map(); let isDirectListening = false; let currentModulation = 'am'; +// Agent mode state +let listeningPostCurrentAgent = null; +let listeningPostPollTimer = null; + // ============== PRESETS ============== const scannerPresets = { @@ -145,6 +149,13 @@ function startScanner() { const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10; 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 (typeof showNotification === 'function') { showNotification('Scanner Error', 'End frequency must be greater than start'); @@ -152,8 +163,8 @@ function startScanner() { return; } - // Check if device is available - if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) { + // Check if device is available (only for local mode) + if (!isAgentMode && typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) { return; } @@ -181,7 +192,12 @@ function startScanner() { 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', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -198,8 +214,11 @@ function startScanner() { }) .then(r => r.json()) .then(data => { - if (data.status === 'started') { - if (typeof reserveDevice === 'function') reserveDevice(device, 'scanner'); + // Handle controller proxy response format + 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; isScannerPaused = false; scannerSignalActive = false; @@ -229,7 +248,7 @@ function startScanner() { const levelMeter = document.getElementById('scannerLevelMeter'); if (levelMeter) levelMeter.style.display = 'block'; - connectScannerStream(); + connectScannerStream(isAgentMode); addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`); if (typeof showNotification === 'function') { showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`); @@ -237,7 +256,7 @@ function startScanner() { } else { updateScannerDisplay('ERROR', 'var(--accent-red)'); 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() { - 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(() => { - if (typeof releaseDevice === 'function') releaseDevice('scanner'); + if (!isAgentMode && typeof releaseDevice === 'function') releaseDevice('scanner'); + listeningPostCurrentAgent = null; isScannerRunning = false; isScannerPaused = 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) const startBtn = document.getElementById('scannerStartBtn'); if (startBtn) { @@ -386,17 +420,29 @@ function skipSignal() { // ============== SCANNER STREAM ============== -function connectScannerStream() { +function connectScannerStream(isAgentMode = false) { if (scannerEventSource) { 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) { try { 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) { console.warn('Scanner parse error:', err); } @@ -404,9 +450,86 @@ function connectScannerStream() { scannerEventSource.onerror = function() { 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) { @@ -576,6 +699,27 @@ function handleSignalLost(data) { 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) { const modeLabel = document.getElementById('scannerModeLabel'); if (modeLabel) { @@ -2286,6 +2430,67 @@ function addSidebarRecentSignal(freq, mod) { // Load bookmarks on init 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 = '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 = '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 window.toggleDirectListen = toggleDirectListen; window.startDirectListen = startDirectListen; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index d6b3438..5dd2920 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -28,6 +28,47 @@ const WiFiMode = (function() { 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 // ========================================================================== @@ -49,6 +90,10 @@ const WiFiMode = (function() { let currentFilter = 'all'; let currentSort = { field: 'rssi', order: 'desc' }; + // Agent state + let showAllAgentsMode = false; // Show combined results from all agents + let lastAgentId = null; // Track agent switches + // Capabilities let capabilities = null; @@ -154,11 +199,43 @@ const WiFiMode = (function() { async function checkCapabilities() { try { - const response = await fetch(`${CONFIG.apiBase}/capabilities`); - if (!response.ok) throw new Error('Failed to fetch capabilities'); + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + let response; - capabilities = await response.json(); - console.log('[WiFiMode] Capabilities:', capabilities); + if (isAgentMode) { + // 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(); populateInterfaceSelect(); @@ -282,17 +359,34 @@ const WiFiMode = (function() { async function startQuickScan() { if (isScanning) return; + // Check for agent mode conflicts + if (!checkAgentConflicts()) { + return; + } + console.log('[WiFiMode] Starting quick scan...'); setScanning(true, 'quick'); try { const iface = elements.interfaceSelect?.value || null; + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const agentName = getCurrentAgentName(); - const response = await fetch(`${CONFIG.apiBase}/scan/quick`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ interface: iface }), - }); + let response; + if (isAgentMode) { + // Route through agent proxy + 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) { const error = await response.json(); @@ -302,20 +396,26 @@ const WiFiMode = (function() { const result = await response.json(); 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 - if (result.error) { - console.error('[WiFiMode] Quick scan error from server:', result.error); - showError(result.error); + if (scanResult.error || scanResult.status === 'error') { + console.error('[WiFiMode] Quick scan error from server:', scanResult.error || scanResult.message); + showError(scanResult.error || scanResult.message || 'Quick scan failed'); setScanning(false); return; } + // Handle agent response format + let accessPoints = scanResult.access_points || scanResult.networks || []; + // Check if we got results - if (!result.access_points || result.access_points.length === 0) { + if (accessPoints.length === 0) { // No error but no results let msg = 'Quick scan found no networks in range.'; - if (result.warnings && result.warnings.length > 0) { - msg += ' Warnings: ' + result.warnings.join('; '); + if (scanResult.warnings && scanResult.warnings.length > 0) { + msg += ' Warnings: ' + scanResult.warnings.join('; '); } console.warn('[WiFiMode] ' + msg); showError(msg + ' Try Deep Scan with monitor mode.'); @@ -323,13 +423,18 @@ const WiFiMode = (function() { return; } + // Tag results with agent source + accessPoints.forEach(ap => { + ap._agent = agentName; + }); + // Show any warnings even on success - if (result.warnings && result.warnings.length > 0) { - console.warn('[WiFiMode] Quick scan warnings:', result.warnings); + if (scanResult.warnings && scanResult.warnings.length > 0) { + console.warn('[WiFiMode] Quick scan warnings:', scanResult.warnings); } // Process results - processQuickScanResult(result); + processQuickScanResult({ ...scanResult, access_points: accessPoints }); // For quick scan, we're done after one scan // But keep polling if user wants continuous updates @@ -346,6 +451,11 @@ const WiFiMode = (function() { async function startDeepScan() { if (isScanning) return; + // Check for agent mode conflicts + if (!checkAgentConflicts()) { + return; + } + console.log('[WiFiMode] Starting deep scan...'); setScanning(true, 'deep'); @@ -353,22 +463,48 @@ const WiFiMode = (function() { const iface = elements.interfaceSelect?.value || null; const band = document.getElementById('wifiBand')?.value || 'all'; const channel = document.getElementById('wifiChannel')?.value || null; + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const 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, - }), - }); + let response; + if (isAgentMode) { + // Route through agent proxy + response = await fetch(`/controller/agents/${currentAgent}/wifi/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + 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) { const error = await response.json(); 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 startEventStream(); } catch (error) { @@ -393,13 +529,17 @@ const WiFiMode = (function() { eventSource = null; } - // Stop deep scan on server - if (scanMode === 'deep') { - try { + // Stop scan on server (local or agent) + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { method: 'POST' }); + } else if (scanMode === 'deep') { 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); @@ -431,12 +571,19 @@ const WiFiMode = (function() { async function checkScanStatus() { 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; - 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); if (status.scan_mode === 'deep') { startEventStream(); @@ -517,8 +664,20 @@ const WiFiMode = (function() { eventSource.close(); } - console.log('[WiFiMode] Starting event stream...'); - eventSource = new EventSource(`${CONFIG.apiBase}/stream`); + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + 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 = () => { console.log('[WiFiMode] Event stream connected'); @@ -527,7 +686,46 @@ const WiFiMode = (function() { eventSource.onmessage = (event) => { try { 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) { console.debug('[WiFiMode] Event parse error:', error); } @@ -745,6 +943,10 @@ const WiFiMode = (function() { const hiddenBadge = network.is_hidden ? 'Hidden' : ''; const newBadge = network.is_new ? 'New' : ''; + // Agent source badge + const agentName = network._agent || 'Local'; + const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote'; + return ` ${escapeHtml(network.security)} ${network.client_count || 0} + + ${escapeHtml(agentName)} + `; } @@ -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 // ========================================================================== @@ -1086,12 +1398,19 @@ const WiFiMode = (function() { exportData, checkCapabilities, + // Agent handling + handleAgentChange, + clearData, + toggleShowAllAgents, + refreshInterfaces, + // Getters getNetworks: () => Array.from(networks.values()), getClients: () => Array.from(clients.values()), getProbes: () => [...probeRequests], isScanning: () => isScanning, getScanMode: () => scanMode, + isShowAllAgents: () => showAllAgentsMode, // Callbacks onNetworkUpdate: (cb) => { onNetworkUpdate = cb; }, diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 286bf93..119f6b2 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -20,6 +20,16 @@ // INTERCEPT - See the Invisible
+ +
+ + + +
Main Dashboard
@@ -59,6 +69,10 @@ 0 ACARS +
+ Local + SOURCE +
-- SIGNAL @@ -74,12 +88,12 @@ - - - 📚 History - + + + 📚 History +
@@ -298,6 +312,7 @@ let markers = {}; let selectedIcao = null; let eventSource = null; + let agentPollTimer = null; // Polling fallback for agent mode let isTracking = false; let currentFilter = 'all'; let alertedAircraft = {}; @@ -1962,14 +1977,14 @@ ACARS: ${r.statistics.acarsMessages} messages`; setInterval(cleanupOldAircraft, 10000); checkAdsbTools(); checkAircraftDatabase(); - checkDvbDriverConflict(); - - // Auto-connect to gpsd if available - autoConnectGps(); - - // Sync tracking state if ADS-B already running - syncTrackingStatus(); - }); + checkDvbDriverConflict(); + + // Auto-connect to gpsd if available + autoConnectGps(); + + // Sync tracking state if ADS-B already running + syncTrackingStatus(); + }); // Track which device is being used for ADS-B tracking let adsbActiveDevice = null; @@ -2368,14 +2383,22 @@ sudo make install return { host, port }; } - async function toggleTracking() { - const btn = document.getElementById('startBtn'); + async function toggleTracking() { + const btn = document.getElementById('startBtn'); + const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local'; if (!isTracking) { - // Check for remote dump1090 config - const remoteConfig = getRemoteDump1090Config(); + // Check for remote dump1090 config (only for local mode) + const remoteConfig = !useAgent ? getRemoteDump1090Config() : null; if (remoteConfig === false) return; + // Check for agent SDR conflicts + if (useAgent && typeof checkAgentModeConflict === 'function') { + if (!checkAgentModeConflict('adsb')) { + return; // User cancelled or conflict not resolved + } + } + // Get selected ADS-B device const adsbDevice = parseInt(document.getElementById('adsbDeviceSelect').value) || 0; @@ -2388,7 +2411,12 @@ sudo make install } try { - const response = await fetch('/adsb/start', { + // Route through agent proxy if using remote agent + const url = useAgent + ? `/controller/agents/${adsbCurrentAgent}/adsb/start` + : '/adsb/start'; + + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) @@ -2409,12 +2437,23 @@ sudo make install startSessionTimer(); isTracking = true; adsbActiveDevice = adsbDevice; // Track which device is being used + adsbTrackingSource = useAgent ? adsbCurrentAgent : 'local'; // Track which source started tracking btn.textContent = 'STOP'; btn.classList.add('active'); document.getElementById('trackingDot').classList.remove('inactive'); - document.getElementById('trackingStatus').textContent = 'TRACKING'; + updateTrackingStatusDisplay(); // Disable ADS-B device selector while tracking document.getElementById('adsbDeviceSelect').disabled = true; + // Disable agent selector while tracking + const agentSelect = document.getElementById('agentSelect'); + if (agentSelect) agentSelect.disabled = true; + + // Update agent running modes tracking + if (useAgent && typeof agentRunningModes !== 'undefined') { + if (!agentRunningModes.includes('adsb')) { + agentRunningModes.push('adsb'); + } + } } else { alert('Failed to start: ' + (data.message || JSON.stringify(data))); } @@ -2423,66 +2462,175 @@ sudo make install } } else { try { - await fetch('/adsb/stop', { method: 'POST' }); + // Route stop through agent proxy if using remote agent + const url = useAgent + ? `/controller/agents/${adsbCurrentAgent}/adsb/stop` + : '/adsb/stop'; + await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + // Update agent running modes tracking + if (useAgent && typeof agentRunningModes !== 'undefined') { + agentRunningModes = agentRunningModes.filter(m => m !== 'adsb'); + } } catch (err) {} stopEventStream(); isTracking = false; adsbActiveDevice = null; + adsbTrackingSource = null; // Reset tracking source btn.textContent = 'START'; btn.classList.remove('active'); document.getElementById('trackingDot').classList.add('inactive'); - document.getElementById('trackingStatus').textContent = 'STANDBY'; + updateTrackingStatusDisplay(); // Re-enable ADS-B device selector - document.getElementById('adsbDeviceSelect').disabled = false; - } - } - - async function syncTrackingStatus() { - try { - const response = await fetch('/adsb/session'); - if (!response.ok) { - return; - } - const data = await response.json(); - if (!data.tracking_active) { - return; - } - isTracking = true; - startEventStream(); - drawRangeRings(); - const startBtn = document.getElementById('startBtn'); - startBtn.textContent = 'STOP'; - startBtn.classList.add('active'); - document.getElementById('trackingDot').classList.remove('inactive'); - document.getElementById('trackingStatus').textContent = 'TRACKING'; - document.getElementById('adsbDeviceSelect').disabled = true; - - const session = data.session || {}; - const startTime = session.started_at ? Date.parse(session.started_at) : null; - if (startTime) { - stats.sessionStart = startTime; - } - startSessionTimer(); - - const sessionDevice = session.device_index; - if (sessionDevice !== null && sessionDevice !== undefined) { - adsbActiveDevice = sessionDevice; - const adsbSelect = document.getElementById('adsbDeviceSelect'); - if (adsbSelect) { - adsbSelect.value = sessionDevice; - } - } - } catch (err) { - console.warn('Failed to sync ADS-B tracking status', err); - } - } - - function startEventStream() { - if (eventSource) eventSource.close(); + document.getElementById('adsbDeviceSelect').disabled = false; + // Re-enable agent selector + const agentSelect = document.getElementById('agentSelect'); + if (agentSelect) agentSelect.disabled = false; + } + } - console.log('Starting ADS-B event stream...'); - eventSource = new EventSource('/adsb/stream'); + async function syncTrackingStatus() { + // This function checks LOCAL tracking status on page load + // For local mode: auto-start if session is already running OR SDR is available + // For agent mode: don't auto-start (user controls agent tracking) + + const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local'; + if (useAgent) { + console.log('[ADS-B] Agent mode on page load - not auto-starting local'); + return; + } + + try { + const response = await fetch('/adsb/session'); + if (!response.ok) { + // No session info - try to auto-start if SDR available + console.log('[ADS-B] No session found, attempting auto-start...'); + await tryAutoStartLocal(); + return; + } + const data = await response.json(); + + if (data.tracking_active) { + // Session is running - auto-connect to stream + console.log('[ADS-B] Local session already active - auto-connecting to stream'); + + // Get session info + const session = data.session || {}; + const startTime = session.started_at ? Date.parse(session.started_at) : null; + if (startTime) { + stats.sessionStart = startTime; + } + + const sessionDevice = session.device_index; + if (sessionDevice !== null && sessionDevice !== undefined) { + adsbActiveDevice = sessionDevice; + const adsbSelect = document.getElementById('adsbDeviceSelect'); + if (adsbSelect) { + adsbSelect.value = sessionDevice; + } + } + + // Auto-connect to the running session + isTracking = true; + adsbTrackingSource = 'local'; + startEventStream(); + drawRangeRings(); + startSessionTimer(); + + const btn = document.getElementById('startBtn'); + if (btn) { + btn.textContent = 'STOP'; + btn.classList.add('active'); + } + document.getElementById('trackingDot').classList.remove('inactive'); + document.getElementById('trackingDot').classList.add('active'); + const statusEl = document.getElementById('trackingStatus'); + statusEl.textContent = 'TRACKING'; + } else { + // Session not active - try to auto-start + console.log('[ADS-B] No active session, attempting auto-start...'); + await tryAutoStartLocal(); + } + + } catch (err) { + console.warn('[ADS-B] Failed to sync tracking status:', err); + // Try auto-start anyway + await tryAutoStartLocal(); + } + } + + async function tryAutoStartLocal() { + // Try to auto-start local ADS-B tracking if SDR is available + try { + // Check if any SDR devices are available + const devResponse = await fetch('/devices'); + if (!devResponse.ok) return; + + const devices = await devResponse.json(); + if (!devices || devices.length === 0) { + console.log('[ADS-B] No SDR devices found - cannot auto-start'); + return; + } + + // Try to start tracking on first available device + const device = devices[0].index !== undefined ? devices[0].index : 0; + console.log(`[ADS-B] Auto-starting local tracking on device ${device}...`); + + const startResponse = await fetch('/adsb/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device: device }) + }); + + const result = await startResponse.json(); + + if (result.status === 'success' || result.status === 'started' || result.status === 'already_running') { + console.log('[ADS-B] Auto-start successful'); + isTracking = true; + adsbActiveDevice = device; + adsbTrackingSource = 'local'; + startEventStream(); + drawRangeRings(); + startSessionTimer(); + + const btn = document.getElementById('startBtn'); + if (btn) { + btn.textContent = 'STOP'; + btn.classList.add('active'); + } + document.getElementById('trackingDot').classList.remove('inactive'); + document.getElementById('trackingDot').classList.add('active'); + const statusEl = document.getElementById('trackingStatus'); + statusEl.textContent = 'TRACKING'; + } else { + // SDR might be in use - don't show error, just don't auto-start + console.log('[ADS-B] Auto-start failed (SDR may be in use):', result.error || result.message); + } + } catch (err) { + console.log('[ADS-B] Auto-start error (SDR may be in use):', err.message); + } + } + + function startEventStream() { + if (eventSource) eventSource.close(); + + const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local'; + const streamUrl = useAgent ? '/controller/stream/all' : '/adsb/stream'; + + console.log(`[ADS-B] startEventStream called - adsbCurrentAgent=${adsbCurrentAgent}, useAgent=${useAgent}, streamUrl=${streamUrl}`); + eventSource = new EventSource(streamUrl); + + // Get agent name for filtering multi-agent stream + let targetAgentName = null; + if (useAgent && typeof agents !== 'undefined') { + const agent = agents.find(a => a.id == adsbCurrentAgent); + targetAgentName = agent ? agent.name : null; + } eventSource.onopen = () => { console.log('ADS-B stream connected'); @@ -2491,34 +2639,158 @@ sudo make install eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); - if (data.type === 'aircraft') { - updateAircraft(data); - } else if (data.type === 'status') { - console.log('ADS-B status:', data.message); - } else if (data.type === 'keepalive') { - // Keepalive received + + if (useAgent) { + // Agent mode - handle multi-agent stream format + // Skip keepalive messages + if (data.type === 'keepalive') return; + + // Filter to only our selected agent + if (targetAgentName && data.agent_name && data.agent_name !== targetAgentName) { + return; + } + + // Extract aircraft data from push payload + if (data.scan_type === 'adsb' && data.payload) { + const payload = data.payload; + if (payload.aircraft) { + // Handle array or object of aircraft + const aircraftList = Array.isArray(payload.aircraft) + ? payload.aircraft + : Object.values(payload.aircraft); + aircraftList.forEach(ac => { + ac._agent = data.agent_name; + updateAircraft({ type: 'aircraft', ...ac }); + }); + } + } } else { - console.log('ADS-B data:', data); + // Local mode - original stream format + if (data.type === 'aircraft') { + updateAircraft(data); + } else if (data.type === 'status') { + console.log('ADS-B status:', data.message); + } else if (data.type === 'keepalive') { + // Keepalive received + } else { + console.log('ADS-B data:', data); + } } } catch (err) { console.error('ADS-B parse error:', err, event.data); } }; - eventSource.onerror = (e) => { - console.error('ADS-B stream error:', e); - if (eventSource.readyState === EventSource.CLOSED) { - console.log('ADS-B stream closed, will not auto-reconnect'); - } - }; - } - - function stopEventStream() { - if (eventSource) { - eventSource.close(); - eventSource = null; + // Start polling as fallback when in agent mode (in case push isn't enabled) + if (useAgent) { + startAgentPolling(); } + + eventSource.onerror = (e) => { + console.error('ADS-B stream error:', e); + if (eventSource.readyState === EventSource.CLOSED) { + console.log('ADS-B stream closed, will not auto-reconnect'); + } + }; + } + + function stopEventStream() { + if (eventSource) { + eventSource.close(); + eventSource = null; } + if (agentPollTimer) { + clearInterval(agentPollTimer); + agentPollTimer = null; + } + } + + /** + * Perform a single poll of agent ADS-B data. + */ + async function doAgentPoll() { + try { + const pollUrl = `/controller/agents/${adsbCurrentAgent}/adsb/data`; + console.log(`[ADS-B Poll] Fetching: ${pollUrl}`); + const response = await fetch(pollUrl); + if (!response.ok) { + console.warn(`[ADS-B Poll] Response not OK: ${response.status}`); + return; + } + + const result = await response.json(); + console.log('[ADS-B Poll] Raw response keys:', Object.keys(result)); + + // Handle double-nested response: result.data.data contains aircraft array + // Structure: { agent_id, agent_name, data: { data: [aircraft], agent_gps, ... } } + let aircraftData = null; + if (result.data && result.data.data) { + // Double nested (controller proxy format) + aircraftData = result.data.data; + console.log('[ADS-B Poll] Found double-nested data, count:', aircraftData.length); + } else if (result.data && Array.isArray(result.data)) { + // Single nested array + aircraftData = result.data; + console.log('[ADS-B Poll] Found single-nested array'); + } else if (Array.isArray(result)) { + // Direct array + aircraftData = result; + console.log('[ADS-B Poll] Found direct array'); + } else { + console.warn('[ADS-B Poll] Unknown data format:', Object.keys(result)); + } + + // Get agent name + let agentName = result.agent_name || 'Agent'; + if (!result.agent_name && typeof agents !== 'undefined') { + const agent = agents.find(a => a.id == adsbCurrentAgent); + if (agent) agentName = agent.name; + } + + // Process aircraft from polling response + if (aircraftData && Array.isArray(aircraftData)) { + console.log(`[ADS-B Poll] Processing ${aircraftData.length} aircraft from ${agentName}`); + aircraftData.forEach(ac => { + if (ac.icao) { // Only process valid aircraft + ac._agent = agentName; + updateAircraft({ type: 'aircraft', ...ac }); + } else { + console.warn('[ADS-B Poll] Aircraft missing icao:', ac); + } + }); + } else if (aircraftData) { + console.warn('[ADS-B Poll] aircraftData is not an array:', typeof aircraftData); + } else { + console.log('[ADS-B Poll] No aircraft data in response'); + } + } catch (err) { + console.error('[ADS-B Poll] Error:', err); + } + } + + /** + * Start polling agent data as fallback when push isn't enabled. + */ + function startAgentPolling() { + if (agentPollTimer) return; + + const pollInterval = 2000; // 2 seconds for ADS-B + console.log(`[ADS-B Poll] Starting agent polling for agent ${adsbCurrentAgent}...`); + + // Do an immediate poll first + doAgentPoll(); + + // Then set up the interval for continuous polling + agentPollTimer = setInterval(() => { + if (!isTracking) { + console.log('[ADS-B Poll] Stopping - isTracking is false'); + clearInterval(agentPollTimer); + agentPollTimer = null; + return; + } + doAgentPoll(); + }, pollInterval); + } // ============================================ // AIRCRAFT UPDATES @@ -2764,6 +3036,9 @@ sudo make install const militaryInfo = isMilitaryAircraft(ac.icao, ac.callsign); const badge = militaryInfo.military ? `MIL` : ''; + // Agent badge if aircraft came from remote agent + const agentBadge = ac._agent ? + `${ac._agent}` : ''; // Vertical rate indicator: arrow up (climbing), arrow down (descending), or dash (level) let vsIndicator = '-'; let vsColor = ''; @@ -2774,7 +3049,7 @@ sudo make install return `
- ${callsign}${badge} + ${callsign}${badge}${agentBadge} ${typeCode ? typeCode + ' • ' : ''}${ac.icao}
@@ -3344,6 +3619,8 @@ sudo make install // ============================================ let acarsEventSource = null; let isAcarsRunning = false; + let acarsCurrentAgent = null; + let acarsPollTimer = null; let acarsMessageCount = 0; let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') === 'true'; let acarsFrequencies = { @@ -3416,8 +3693,12 @@ sudo make install const device = document.getElementById('acarsDeviceSelect').value; const frequencies = getAcarsRegionFreqs(); - // Warn if using same device as ADS-B - if (isTracking && device === '0') { + // Check if using agent mode + const isAgentMode = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local'; + acarsCurrentAgent = isAgentMode ? adsbCurrentAgent : null; + + // Warn if using same device as ADS-B (only for local mode) + if (!isAgentMode && isTracking && device === '0') { const useAnyway = confirm( 'Warning: ADS-B tracking may be using SDR device 0.\n\n' + 'ACARS uses VHF frequencies (129-131 MHz) while ADS-B uses 1090 MHz.\n' + @@ -3427,32 +3708,46 @@ sudo make install if (!useAnyway) return; } - fetch('/acars/start', { + // Determine endpoint based on agent mode + const endpoint = isAgentMode + ? `/controller/agents/${adsbCurrentAgent}/acars/start` + : '/acars/start'; + + fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device, frequencies, gain: '40' }) }) .then(r => r.json()) .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') { isAcarsRunning = true; acarsMessageCount = 0; document.getElementById('acarsToggleBtn').textContent = '■ STOP ACARS'; document.getElementById('acarsToggleBtn').classList.add('active'); document.getElementById('acarsPanelIndicator').classList.add('active'); - startAcarsStream(); + startAcarsStream(isAgentMode); } else { - alert('ACARS Error: ' + data.message); + alert('ACARS Error: ' + (scanResult.message || scanResult.error || 'Failed to start')); } }) .catch(err => alert('ACARS Error: ' + err)); } function stopAcars() { - fetch('/acars/stop', { method: 'POST' }) + const isAgentMode = acarsCurrentAgent !== null; + const endpoint = isAgentMode + ? `/controller/agents/${acarsCurrentAgent}/acars/stop` + : '/acars/stop'; + + fetch(endpoint, { method: 'POST' }) .then(r => r.json()) .then(() => { isAcarsRunning = false; + acarsCurrentAgent = null; document.getElementById('acarsToggleBtn').textContent = '▶ START ACARS'; document.getElementById('acarsToggleBtn').classList.remove('active'); document.getElementById('acarsPanelIndicator').classList.remove('active'); @@ -3460,27 +3755,123 @@ sudo make install acarsEventSource.close(); acarsEventSource = null; } + // Clear polling timer + if (acarsPollTimer) { + clearInterval(acarsPollTimer); + acarsPollTimer = null; + } }); } - function startAcarsStream() { + // Sync ACARS UI state (called by syncModeUI in agents.js) + function setAcarsRunning(running, agentId = null) { + isAcarsRunning = running; + const btn = document.getElementById('acarsToggleBtn'); + const indicator = document.getElementById('acarsPanelIndicator'); + + if (running) { + acarsCurrentAgent = agentId; + btn.textContent = '■ STOP ACARS'; + btn.classList.add('active'); + if (indicator) indicator.classList.add('active'); + // Start stream if not already running + if (!acarsEventSource && !acarsPollTimer) { + startAcarsStream(agentId !== null); + } + } else { + btn.textContent = '▶ START ACARS'; + btn.classList.remove('active'); + if (indicator) indicator.classList.remove('active'); + } + } + // Expose to global scope for syncModeUI + window.setAcarsRunning = setAcarsRunning; + + function startAcarsStream(isAgentMode = false) { if (acarsEventSource) acarsEventSource.close(); - acarsEventSource = new EventSource('/acars/stream'); + + // Use different stream endpoint for agent mode + const streamUrl = isAgentMode ? '/controller/stream/all' : '/acars/stream'; + acarsEventSource = new EventSource(streamUrl); acarsEventSource.onmessage = function(e) { const data = JSON.parse(e.data); - if (data.type === 'acars') { - acarsMessageCount++; - stats.acarsMessages++; - document.getElementById('acarsCount').textContent = acarsMessageCount; - document.getElementById('stripAcars').textContent = stats.acarsMessages; - addAcarsMessage(data); + + if (isAgentMode) { + // Handle multi-agent stream format + if (data.scan_type === 'acars' && data.payload) { + const payload = data.payload; + if (payload.type === 'acars') { + acarsMessageCount++; + stats.acarsMessages++; + document.getElementById('acarsCount').textContent = acarsMessageCount; + document.getElementById('stripAcars').textContent = stats.acarsMessages; + payload.agent_name = data.agent_name; + addAcarsMessage(payload); + } + } + } else { + // Local stream format + if (data.type === 'acars') { + acarsMessageCount++; + stats.acarsMessages++; + document.getElementById('acarsCount').textContent = acarsMessageCount; + document.getElementById('stripAcars').textContent = stats.acarsMessages; + addAcarsMessage(data); + } } }; acarsEventSource.onerror = function() { console.error('ACARS stream error'); }; + + // Start polling fallback for agent mode + if (isAgentMode) { + startAcarsPolling(); + } + } + + // Track last ACARS message count for polling + let lastAcarsMessageCount = 0; + + function startAcarsPolling() { + if (acarsPollTimer) return; + lastAcarsMessageCount = 0; + + const pollInterval = 2000; + acarsPollTimer = setInterval(async () => { + if (!isAcarsRunning || !acarsCurrentAgent) { + clearInterval(acarsPollTimer); + acarsPollTimer = null; + return; + } + + try { + const response = await fetch(`/controller/agents/${acarsCurrentAgent}/acars/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 > lastAcarsMessageCount) { + const newMessages = messages.slice(lastAcarsMessageCount); + newMessages.forEach(msg => { + acarsMessageCount++; + stats.acarsMessages++; + document.getElementById('acarsCount').textContent = acarsMessageCount; + document.getElementById('stripAcars').textContent = stats.acarsMessages; + msg.agent_name = result.agent_name || 'Remote Agent'; + addAcarsMessage(msg); + }); + lastAcarsMessageCount = messages.length; + } + } catch (err) { + console.error('ACARS polling error:', err); + } + }, pollInterval); } function addAcarsMessage(data) { @@ -3967,6 +4358,452 @@ sudo make install color: var(--accent-cyan, #00d4ff); font-size: 10px; } + + /* Agent selector styles */ + .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; + } + .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; + } + + + + diff --git a/templates/agents.html b/templates/agents.html new file mode 100644 index 0000000..00b98fe --- /dev/null +++ b/templates/agents.html @@ -0,0 +1,555 @@ + + + + + + + iNTERCEPT // Remote Agents + + + + + + + +
+
+ + + + + + + + + + + + +
+

+ iNTERCEPT // Remote Agents +

+
+ +
+ + + + + Back to Dashboard + + +
+

Remote Agents

+ +
+ + +
+

Register New Agent

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+
+ + +
+ +
+ +
+
+ + + + + + +
+

No Remote Agents

+

Register your first remote agent to get started with distributed signal intelligence.

+
+
+ + + + diff --git a/templates/ais_dashboard.html b/templates/ais_dashboard.html index 1a90afa..8b635a2 100644 --- a/templates/ais_dashboard.html +++ b/templates/ais_dashboard.html @@ -21,6 +21,16 @@ // INTERCEPT - AIS Tracking
+ +
+ + + +
Main Dashboard
@@ -173,6 +183,7 @@ let markers = {}; let selectedMmsi = null; let eventSource = null; + let aisPollTimer = null; // Polling fallback for agent mode let isTracking = false; // DSC State @@ -181,6 +192,8 @@ let dscMessages = {}; let dscMarkers = {}; let dscAlertCounts = { distress: 0, urgency: 0 }; + let dscCurrentAgent = null; + let dscPollTimer = null; let showTrails = false; let vesselTrails = {}; let trailLines = {}; @@ -490,6 +503,40 @@ const device = document.getElementById('aisDeviceSelect').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', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -513,7 +560,12 @@ } 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(() => { isTracking = false; @@ -527,18 +579,107 @@ eventSource.close(); 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() { 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) { try { 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) {} }; @@ -731,12 +872,13 @@ container.innerHTML = vesselArray.map(v => { const iconSvg = getShipIconSvg(v.ship_type, 20); const category = getShipCategory(v.ship_type); + const agentBadge = v._agent ? `${v._agent}` : ''; return `
${iconSvg}
-
${v.name || 'Unknown'}
+
${v.name || 'Unknown'}${agentBadge}
${category} | ${v.mmsi}
${v.speed ? v.speed + ' kt' : '-'}
@@ -881,33 +1023,51 @@ const device = document.getElementById('dscDeviceSelect').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', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device, gain }) }) .then(r => r.json()) .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; document.getElementById('dscStartBtn').textContent = 'STOP DSC'; document.getElementById('dscStartBtn').classList.add('active'); document.getElementById('dscIndicator').classList.add('active'); - startDscSSE(); - } else if (data.error_type === 'DEVICE_BUSY') { - alert('SDR device is busy.\n\n' + data.suggestion); + startDscSSE(isAgentMode); + } else if (scanResult.error_type === 'DEVICE_BUSY') { + alert('SDR device is busy.\n\n' + (scanResult.suggestion || '')); } else { - alert(data.message || 'Failed to start DSC'); + alert(scanResult.message || scanResult.error || 'Failed to start DSC'); } }) .catch(err => alert('Error: ' + err.message)); } 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(() => { isDscTracking = false; + dscCurrentAgent = null; document.getElementById('dscStartBtn').textContent = 'START DSC'; document.getElementById('dscStartBtn').classList.remove('active'); document.getElementById('dscIndicator').classList.remove('active'); @@ -915,23 +1075,50 @@ dscEventSource.close(); dscEventSource = null; } + // Clear polling timer + if (dscPollTimer) { + clearInterval(dscPollTimer); + dscPollTimer = null; + } }); } - function startDscSSE() { + function startDscSSE(isAgentMode = false) { 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) { try { const data = JSON.parse(e.data); - 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(); + + if (isAgentMode) { + // Handle multi-agent stream format + if (data.scan_type === 'dsc' && data.payload) { + const payload = data.payload; + if (payload.type === 'dsc_message') { + 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) {} @@ -939,9 +1126,56 @@ dscEventSource.onerror = function() { setTimeout(() => { - if (isDscTracking) startDscSSE(); + if (isDscTracking) startDscSSE(isAgentMode); }, 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) { @@ -1100,5 +1334,324 @@ // Initialize document.addEventListener('DOMContentLoaded', initMap); + + + + + + + diff --git a/templates/index.html b/templates/index.html index 9af3a04..3ad86e0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -329,6 +329,8 @@ + + + + + + + +
+
+
+
📡
+
Waiting for agent data...
+
Start modes on connected agents to see aggregated data here
+
+ + + + + + + + + + + + +
TypeIdentifierDetailsSourcesLast Update
+
+
+ +
+
+
+ Connected Agents +
+
+
+
No agents registered
+
Add agents
+
+
+
+ +
+
+ Event Log +
+
+
+ --:--:-- + Waiting for events... +
+
+
+
+ + + + + diff --git a/templates/partials/modes/bluetooth.html b/templates/partials/modes/bluetooth.html index 9c6efc0..fe0006c 100644 --- a/templates/partials/modes/bluetooth.html +++ b/templates/partials/modes/bluetooth.html @@ -5,6 +5,14 @@
+ +
+ +
+

Scanner Configuration

diff --git a/templates/partials/modes/wifi.html b/templates/partials/modes/wifi.html index 6885964..fa7d677 100644 --- a/templates/partials/modes/wifi.html +++ b/templates/partials/modes/wifi.html @@ -11,6 +11,13 @@
+ +
+ +
diff --git a/templates/satellite_dashboard.html b/templates/satellite_dashboard.html index 3d1ea98..f992604 100644 --- a/templates/satellite_dashboard.html +++ b/templates/satellite_dashboard.html @@ -38,6 +38,14 @@
+ +
+ Location: + + +
TRACKING @@ -183,6 +191,49 @@
+