From b72ddd7c1936c7e9b5dc5ae093d8067adfdcd13f Mon Sep 17 00:00:00 2001 From: cemaxecuter Date: Mon, 26 Jan 2026 11:44:54 -0500 Subject: [PATCH] Enhance distributed agent architecture with full mode support and reliability Agent improvements: - Add process verification (0.5s delay + poll check) for sensor, pager, APRS, DSC modes - Prevents silent failures when SDR is busy or tools fail to start - Returns clear error messages when subprocess exits immediately Frontend agent integration: - Add agent routing to all SDR modes (pager, sensor, RTLAMR, APRS, listening post, TSCM) - Add agent routing to WiFi and Bluetooth modes with polling fallback - Add agent routing to AIS and DSC dashboards - Implement "Show All Agents" toggle for Bluetooth mode - Add agent badges to device/network lists - Handle controller proxy response format (nested 'result' field) Controller enhancements: - Add running_modes_detail endpoint showing device info per mode - Support SDR conflict detection across modes Documentation: - Expand DISTRIBUTED_AGENTS.md with complete API reference - Add troubleshooting guide and security considerations - Document all supported modes with tools and data formats UI/CSS: - Add agent badge styling for remote vs local sources - Add WiFi and Bluetooth table agent columns --- docs/DISTRIBUTED_AGENTS.md | 97 ++ intercept_agent.py | 1389 ++++++++++++++++++++++- routes/controller.py | 27 + static/css/adsb_dashboard.css | 6 + static/css/agents.css | 24 +- static/js/core/agents.js | 361 +++++- static/js/modes/bluetooth.js | 393 ++++++- static/js/modes/listening-post.js | 125 +- static/js/modes/wifi.js | 382 ++++++- templates/adsb_dashboard.html | 990 ++++++++++++++-- templates/ais_dashboard.html | 597 +++++++++- templates/index.html | 613 ++++++++-- templates/partials/modes/bluetooth.html | 8 + templates/partials/modes/wifi.html | 7 + 14 files changed, 4710 insertions(+), 309 deletions(-) diff --git a/docs/DISTRIBUTED_AGENTS.md b/docs/DISTRIBUTED_AGENTS.md index 8aa0d8c..7aa9f99 100644 --- a/docs/DISTRIBUTED_AGENTS.md +++ b/docs/DISTRIBUTED_AGENTS.md @@ -252,6 +252,45 @@ Response: } ``` +## 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 @@ -396,6 +435,62 @@ bluetooth = true 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 | @@ -407,3 +502,5 @@ bluetooth = true | `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.py b/intercept_agent.py index e432204..93dd952 100644 --- a/intercept_agent.py +++ b/intercept_agent.py @@ -499,8 +499,18 @@ class ModeManager: 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, @@ -512,6 +522,26 @@ class ModeManager: 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: @@ -521,6 +551,20 @@ class ModeManager: 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() @@ -574,6 +618,23 @@ class ModeManager: 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) return info return {'running': False} @@ -599,6 +660,27 @@ class ModeManager: } 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', {}), + } + elif mode == 'listening_post': + data['data'] = { + 'activity': getattr(self, 'listening_post_activity', []), + 'current_freq': getattr(self, 'listening_post_current_freq', 0), + } + 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), + } else: data['data'] = self.data_snapshots.get(mode, []) @@ -623,15 +705,24 @@ class ModeManager: '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) - # Default stub for modes not yet implemented - logger.warning(f"Mode {mode} not yet implemented - running in stub mode") - return {'status': 'started', 'mode': mode, 'stub': True} + # 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.""" @@ -725,6 +816,13 @@ class ModeManager: ) 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, @@ -1042,7 +1140,13 @@ class ModeManager: 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 requires interface if not interface: return {'status': 'error', 'message': 'WiFi interface required'} @@ -1130,6 +1234,112 @@ class ModeManager: 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' @@ -1406,6 +1616,1179 @@ class ModeManager: 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.""" + # 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 _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.'} + + # Build command with JSON output + cmd = [acarsdec_path, '-j', '-r', str(device), '-g', str(gain)] + for freq in frequencies: + cmd.append(freq) + + 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.""" + 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'} + + # Try to find dsc-decoder + dsc_decoder = None + for path in ['/usr/local/bin/dsc-decoder', '/usr/bin/dsc-decoder', './bin/dsc-decoder']: + if os.path.isfile(path) and os.access(path, os.X_OK): + dsc_decoder = path + break + + # 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: + if dsc_decoder: + rtl_fm_proc = subprocess.Popen( + rtl_fm_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + decoder_proc = subprocess.Popen( + [dsc_decoder], + stdin=rtl_fm_proc.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + rtl_fm_proc.stdout.close() + self.processes['dsc'] = decoder_proc + self.processes['dsc_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['dsc'] + del self.processes['dsc_rtl'] + return {'status': 'error', 'message': f'rtl_fm failed to start: {stderr_output[:200]}'} + else: + rtl_fm_proc = subprocess.Popen( + rtl_fm_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.processes['dsc'] = rtl_fm_proc + logger.warning("No dsc-decoder found - DSC decoding limited") + + # 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]}'} + + return { + 'status': 'started', + 'mode': 'dsc', + 'frequency': freq, + 'channel': 70, + 'has_decoder': dsc_decoder is not None, + 'gps_enabled': gps_manager.is_running + } + + except Exception as e: + return {'status': 'error', 'message': str(e)} + + # ------------------------------------------------------------------------- + # TSCM MODE (Technical Surveillance Countermeasures) + # ------------------------------------------------------------------------- + + def _start_tscm(self, params: dict) -> dict: + """Start TSCM scanning - combines WiFi and Bluetooth analysis.""" + # Initialize state + if not hasattr(self, 'tscm_baseline'): + self.tscm_baseline = {} + if not hasattr(self, 'tscm_anomalies'): + self.tscm_anomalies = [] + self.tscm_anomalies.clear() + + thread = threading.Thread( + target=self._tscm_analyzer, + daemon=True + ) + thread.start() + self.output_threads['tscm'] = thread + + return { + 'status': 'started', + 'mode': 'tscm', + 'note': 'TSCM analyzes WiFi/BT data for anomalies - no SDR required', + 'gps_enabled': gps_manager.is_running + } + + def _tscm_analyzer(self): + """Background TSCM analysis - looks for anomalies in WiFi/BT.""" + mode = 'tscm' + stop_event = self.stop_events.get(mode) + baseline_built = False + + while not (stop_event and stop_event.is_set()): + try: + current_wifi = dict(self.wifi_networks) + current_bt = dict(self.bluetooth_devices) + + if not baseline_built and (current_wifi or current_bt): + self.tscm_baseline = { + 'wifi': {k: {'rssi': v.get('signal'), 'essid': v.get('essid')} + for k, v in current_wifi.items()}, + 'bluetooth': {k: {'rssi': v.get('rssi'), 'name': v.get('name')} + for k, v in current_bt.items()}, + 'built_at': datetime.now(timezone.utc).isoformat() + } + baseline_built = True + logger.info(f"TSCM baseline: {len(current_wifi)} WiFi, {len(current_bt)} BT") + + elif baseline_built: + anomalies = [] + + for bssid, network in current_wifi.items(): + if bssid not in self.tscm_baseline.get('wifi', {}): + anomalies.append({ + 'type': 'new_wifi', + 'severity': 'medium', + 'bssid': bssid, + 'essid': network.get('essid'), + 'rssi': network.get('signal'), + 'detected_at': datetime.now(timezone.utc).isoformat() + }) + + for mac, device in current_bt.items(): + if mac not in self.tscm_baseline.get('bluetooth', {}): + anomalies.append({ + 'type': 'new_bluetooth', + 'severity': 'medium', + 'mac': mac, + 'name': device.get('name'), + 'rssi': device.get('rssi'), + 'detected_at': datetime.now(timezone.utc).isoformat() + }) + + if anomalies: + self.tscm_anomalies.extend(anomalies) + if len(self.tscm_anomalies) > 100: + self.tscm_anomalies = self.tscm_anomalies[-100:] + + for anomaly in anomalies: + logger.info(f"TSCM anomaly: {anomaly['type']}") + + self.data_snapshots[mode] = self.tscm_anomalies.copy() + + time.sleep(5) + + except Exception as e: + logger.error(f"TSCM analyzer error: {e}") + time.sleep(5) + + logger.info("TSCM analyzer 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 = params.get('step', 0.1) + modulation = params.get('modulation', 'wfm') + squelch = params.get('squelch', 20) + device = params.get('device', '0') + gain = params.get('gain', '40') + + rtl_fm_path = self._get_tool_path('rtl_fm') + if not rtl_fm_path: + return {'status': 'error', 'message': 'rtl_fm not found'} + + # 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)), + 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, + '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): + """Scan frequency range and report signal detections.""" + 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 + + 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, + ) + + signal_detected = False + start_time = time.time() + + while time.time() - start_time < 1.0: + if stop_event and stop_event.is_set(): + break + 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 + break + except Exception: + pass + + proc.terminate() + try: + proc.wait(timeout=1) + except subprocess.TimeoutExpired: + proc.kill() + + 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() diff --git a/routes/controller.py b/routes/controller.py index a8b54ce..9428bbd 100644 --- a/routes/controller.py +++ b/routes/controller.py @@ -240,6 +240,33 @@ def refresh_agent_metadata(agent_id: int): }), 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 + + # ============================================================================= # Proxy Operations - Forward requests to agents # ============================================================================= 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 index 6059be4..1d793f0 100644 --- a/static/css/agents.css +++ b/static/css/agents.css @@ -206,11 +206,33 @@ font-family: 'JetBrains Mono', monospace; } -.agent-badge.local { +.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; diff --git a/static/js/core/agents.js b/static/js/core/agents.js index 6827709..e2458ed 100644 --- a/static/js/core/agents.js +++ b/static/js/core/agents.js @@ -10,6 +10,8 @@ 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) // ============== AGENT LOADING ============== @@ -54,6 +56,28 @@ function updateAgentSelector() { } 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() { @@ -88,10 +112,36 @@ function selectAgent(agentId) { if (typeof refreshDevices === 'function') { refreshDevices(); } + // Refresh TSCM devices if function exists + 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(); + } 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(); + } const agentName = agents.find(a => a.id == agentId)?.name || 'Unknown'; console.log(`Agent selected: ${agentName}`); @@ -104,6 +154,287 @@ function selectAgent(agentId) { } } +/** + * 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); + } +} + +/** + * 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' + }; + + 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 { @@ -430,14 +761,36 @@ function handleMultiAgentData(data) { 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); + } }); - // Update WiFi display if handler exists - if (typeof WiFiMode !== 'undefined' && WiFiMode.updateNetworks) { - WiFiMode.updateNetworks(payload.networks); - } } break; diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index be7e4f4..f6b3cee 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']); @@ -599,32 +670,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 +733,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 +785,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 +871,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 +1086,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 +1140,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 +1234,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 +1354,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..2e1cb74 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,10 @@ 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; + if (startFreq >= endFreq) { if (typeof showNotification === 'function') { showNotification('Scanner Error', 'End frequency must be greater than start'); @@ -152,8 +160,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 +189,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 +211,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 +245,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 +253,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 +268,25 @@ 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; + // Clear polling timer + if (listeningPostPollTimer) { + clearInterval(listeningPostPollTimer); + listeningPostPollTimer = null; + } + // Update sidebar (with null checks) const startBtn = document.getElementById('scannerStartBtn'); if (startBtn) { @@ -386,17 +414,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 +444,68 @@ 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; + + 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; + const modeData = result.data || {}; + + // 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 + }); + } + } catch (err) { + console.error('Listening Post polling error:', err); + } + }, pollInterval); } function handleScannerEvent(data) { diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index d6b3438..231ac3f 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); @@ -517,8 +657,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 +679,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 +936,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 +1269,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 +1391,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..9153232 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,171 @@ 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' }); + + // 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 +2635,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 +3032,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 +3045,7 @@ sudo make install return `
- ${callsign}${badge} + ${callsign}${badge}${agentBadge} ${typeCode ? typeCode + ' • ' : ''}${ac.icao}
@@ -3344,6 +3615,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 +3689,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 +3704,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 +3751,99 @@ sudo make install acarsEventSource.close(); acarsEventSource = null; } + // Clear polling timer + if (acarsPollTimer) { + clearInterval(acarsPollTimer); + acarsPollTimer = null; + } }); } - function startAcarsStream() { + 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 +4330,423 @@ 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/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 b22fcba..0856ba7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -574,11 +574,12 @@ Signal Security Clients + Source - +
Start scanning to discover networks
@@ -2306,6 +2307,11 @@ // Check if using remote agent if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') { + // Check for conflicts with other running SDR modes + if (typeof checkAgentModeConflict === 'function' && !checkAgentModeConflict('sensor')) { + return; // User cancelled or conflict not resolved + } + // Route through agent proxy const config = { frequency: freq, @@ -2320,12 +2326,14 @@ body: JSON.stringify(config) }).then(r => r.json()) .then(data => { - if (data.status === 'started' || data.status === 'success') { + // Handle controller proxy response (agent response is nested in 'result') + const scanResult = data.result || data; + if (scanResult.status === 'started' || scanResult.status === 'success') { setSensorRunning(true); startAgentSensorStream(); showInfo(`Sensor started on remote agent`); } else { - alert('Error: ' + (data.message || 'Failed to start sensor on agent')); + alert('Error: ' + (scanResult.message || 'Failed to start sensor on agent')); } }) .catch(err => { @@ -2612,6 +2620,10 @@ document.getElementById('rtlamrFrequency').value = freq; } + // RTLAMR mode polling timer for agent mode + let rtlamrPollTimer = null; + let rtlamrCurrentAgent = null; + function startRtlamrDecoding() { const freq = document.getElementById('rtlamrFrequency').value; const gain = document.getElementById('rtlamrGain').value; @@ -2621,8 +2633,12 @@ const filterid = document.getElementById('rtlamrFilterId').value; const unique = document.getElementById('rtlamrUnique').checked; - // Check if device is available - if (!checkDeviceAvailability('rtlamr')) { + // Check if using agent mode + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + rtlamrCurrentAgent = isAgentMode ? currentAgent : null; + + // Check if device is available (only for local mode) + if (!isAgentMode && !checkDeviceAvailability('rtlamr')) { return; } @@ -2637,16 +2653,26 @@ format: 'json' }; - fetch('/start_rtlamr', { + // Determine endpoint based on agent mode + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/rtlamr/start` + : '/start_rtlamr'; + + fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) }).then(r => r.json()) .then(data => { - if (data.status === 'started') { - reserveDevice(parseInt(device), 'rtlamr'); + // Handle controller proxy response format + const scanResult = isAgentMode && data.result ? data.result : data; + + if (scanResult.status === 'started' || scanResult.status === 'success') { + if (!isAgentMode) { + reserveDevice(parseInt(device), 'rtlamr'); + } setRtlamrRunning(true); - startRtlamrStream(); + startRtlamrStream(isAgentMode); // Initialize meter filter bar (reuse sensor filter bar since same structure) const filterContainer = document.getElementById('filterBarContainer'); @@ -2667,21 +2693,34 @@ // Clear existing output output.innerHTML = ''; } else { - alert('Error: ' + data.message); + alert('Error: ' + (scanResult.message || scanResult.error || 'Failed to start')); } }); } function stopRtlamrDecoding() { - fetch('/stop_rtlamr', { method: 'POST' }) + const isAgentMode = rtlamrCurrentAgent !== null; + const endpoint = isAgentMode + ? `/controller/agents/${rtlamrCurrentAgent}/rtlamr/stop` + : '/stop_rtlamr'; + + fetch(endpoint, { method: 'POST' }) .then(r => r.json()) .then(data => { - releaseDevice('rtlamr'); + if (!isAgentMode) { + releaseDevice('rtlamr'); + } + rtlamrCurrentAgent = null; setRtlamrRunning(false); if (eventSource) { eventSource.close(); eventSource = null; } + // Clear polling timer + if (rtlamrPollTimer) { + clearInterval(rtlamrPollTimer); + rtlamrPollTimer = null; + } }); } @@ -2701,12 +2740,14 @@ } } - function startRtlamrStream() { + function startRtlamrStream(isAgentMode = false) { if (eventSource) { eventSource.close(); } - eventSource = new EventSource('/stream_rtlamr'); + // Use different stream endpoint for agent mode + const streamUrl = isAgentMode ? '/controller/stream/all' : '/stream_rtlamr'; + eventSource = new EventSource(streamUrl); eventSource.onopen = function () { showInfo('RTLAMR stream connected...'); @@ -2714,20 +2755,86 @@ eventSource.onmessage = function (e) { const data = JSON.parse(e.data); - if (data.type === 'rtlamr') { - addRtlamrReading(data); - } else if (data.type === 'status') { - if (data.text === 'stopped') { - setRtlamrRunning(false); + + if (isAgentMode) { + // Handle multi-agent stream format + if (data.scan_type === 'rtlamr' && data.payload) { + const payload = data.payload; + if (payload.type === 'rtlamr') { + payload.agent_name = data.agent_name; + addRtlamrReading(payload); + } else if (payload.type === 'status') { + if (payload.text === 'stopped') { + setRtlamrRunning(false); + } + } else if (payload.type === 'info' || payload.type === 'raw') { + showInfo(`[${data.agent_name}] ${payload.text}`); + } + } + } else { + // Local stream format + if (data.type === 'rtlamr') { + addRtlamrReading(data); + } else if (data.type === 'status') { + if (data.text === 'stopped') { + setRtlamrRunning(false); + } + } else if (data.type === 'info' || data.type === 'raw') { + showInfo(data.text); } - } else if (data.type === 'info' || data.type === 'raw') { - showInfo(data.text); } }; eventSource.onerror = function (e) { console.error('RTLAMR stream error'); }; + + // Start polling fallback for agent mode + if (isAgentMode) { + startRtlamrPolling(); + } + } + + // Track last reading count for polling + let lastRtlamrReadingCount = 0; + + function startRtlamrPolling() { + if (rtlamrPollTimer) return; + lastRtlamrReadingCount = 0; + + const pollInterval = 2000; + rtlamrPollTimer = setInterval(async () => { + if (!isRtlamrRunning || !rtlamrCurrentAgent) { + clearInterval(rtlamrPollTimer); + rtlamrPollTimer = null; + return; + } + + try { + const response = await fetch(`/controller/agents/${rtlamrCurrentAgent}/rtlamr/data`); + if (!response.ok) return; + + const data = await response.json(); + const result = data.result || data; + const readings = result.data || []; + + // Process new readings + if (readings.length > lastRtlamrReadingCount) { + const newReadings = readings.slice(lastRtlamrReadingCount); + newReadings.forEach(reading => { + const displayReading = { + type: 'rtlamr', + ...reading, + agent_name: result.agent_name || 'Remote Agent' + }; + addRtlamrReading(displayReading); + }); + lastRtlamrReadingCount = readings.length; + } + } catch (err) { + console.error('RTLAMR polling error:', err); + } + }, pollInterval); } function addRtlamrReading(data) { @@ -3196,6 +3303,9 @@ return protocols; } + // Pager mode polling timer for agent mode + let pagerPollTimer = null; + function startDecoding() { const freq = document.getElementById('frequency').value; const gain = document.getElementById('gain').value; @@ -3209,13 +3319,16 @@ return; } - // Check if device is available - if (!checkDeviceAvailability('pager')) { + // Check if using agent mode + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + + // Check if device is available (only for local mode) + if (!isAgentMode && !checkDeviceAvailability('pager')) { return; } - // Check for remote SDR - const remoteConfig = getRemoteSDRConfig(); + // Check for remote SDR (only for local mode) + const remoteConfig = isAgentMode ? null : getRemoteSDRConfig(); if (remoteConfig === false) return; // Validation failed const config = { @@ -3229,22 +3342,32 @@ bias_t: getBiasTEnabled() }; - // Add rtl_tcp params if using remote SDR + // Add rtl_tcp params if using remote SDR (local mode only) if (remoteConfig) { config.rtl_tcp_host = remoteConfig.host; config.rtl_tcp_port = remoteConfig.port; } - fetch('/start', { + // Determine endpoint based on agent mode + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/pager/start` + : '/start'; + + fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) }).then(r => r.json()) .then(data => { - if (data.status === 'started') { - reserveDevice(parseInt(device), 'pager'); + // 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 === 'success') { + if (!isAgentMode) { + reserveDevice(parseInt(device), 'pager'); + } setRunning(true); - startStream(); + startStream(isAgentMode); // Initialize filter bar const filterContainer = document.getElementById('filterBarContainer'); @@ -3260,24 +3383,37 @@ // Clear address history for fresh session SignalCards.clearAddressHistory('pager'); } else { - alert('Error: ' + data.message); + alert('Error: ' + (scanResult.message || scanResult.error || 'Failed to start pager decoding')); } }) .catch(err => { console.error('Start error:', err); + alert('Error starting pager decoding: ' + err.message); }); } function stopDecoding() { - fetch('/stop', { method: 'POST' }) + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/pager/stop` + : '/stop'; + + fetch(endpoint, { method: 'POST' }) .then(r => r.json()) .then(data => { - releaseDevice('pager'); + if (!isAgentMode) { + releaseDevice('pager'); + } setRunning(false); if (eventSource) { eventSource.close(); eventSource = null; } + // Clear polling timer if active + if (pagerPollTimer) { + clearInterval(pagerPollTimer); + pagerPollTimer = null; + } }); } @@ -3342,12 +3478,14 @@ document.getElementById('stopBtn').style.display = running ? 'block' : 'none'; } - function startStream() { + function startStream(isAgentMode = false) { if (eventSource) { eventSource.close(); } - eventSource = new EventSource('/stream'); + // Use different stream endpoint for agent mode + const streamUrl = isAgentMode ? '/controller/stream/all' : '/stream'; + eventSource = new EventSource(streamUrl); eventSource.onopen = function () { showInfo('Stream connected...'); @@ -3356,24 +3494,101 @@ eventSource.onmessage = function (e) { const data = JSON.parse(e.data); - if (data.type === 'message') { - addMessage(data); - } else if (data.type === 'status') { - if (data.text === 'stopped') { - setRunning(false); - } else if (data.text === 'started') { - showInfo('Decoder started, waiting for signals...'); + // Handle multi-agent stream format + if (isAgentMode) { + // Multi-agent stream tags data with scan_type and agent_name + if (data.scan_type === 'pager' && data.payload) { + const payload = data.payload; + if (payload.type === 'message') { + // Add agent info to the message + payload.agent_name = data.agent_name; + addMessage(payload); + } else if (payload.type === 'status') { + if (payload.text === 'stopped') { + setRunning(false); + } else if (payload.text === 'started') { + showInfo(`Decoder started on ${data.agent_name}, waiting for signals...`); + } + } else if (payload.type === 'info') { + showInfo(`[${data.agent_name}] ${payload.text}`); + } + } else if (data.type === 'keepalive') { + // Ignore keepalive messages + } + } else { + // Local stream format + if (data.type === 'message') { + addMessage(data); + } else if (data.type === 'status') { + if (data.text === 'stopped') { + setRunning(false); + } else if (data.text === 'started') { + showInfo('Decoder started, waiting for signals...'); + } + } else if (data.type === 'info') { + showInfo(data.text); + } else if (data.type === 'raw') { + showInfo(data.text); } - } else if (data.type === 'info') { - showInfo(data.text); - } else if (data.type === 'raw') { - showInfo(data.text); } }; eventSource.onerror = function (e) { checkStatus(); }; + + // Start polling fallback for agent mode (in case push isn't enabled) + if (isAgentMode) { + startPagerPolling(); + } + } + + // Track last message count to avoid duplicates during polling + let lastPagerMsgCount = 0; + + function startPagerPolling() { + if (pagerPollTimer) return; + lastPagerMsgCount = 0; + + const pollInterval = 2000; // 2 seconds + pagerPollTimer = setInterval(async () => { + if (!isRunning) { + clearInterval(pagerPollTimer); + pagerPollTimer = null; + return; + } + + try { + const response = await fetch(`/controller/agents/${currentAgent}/pager/data`); + if (!response.ok) return; + + const data = await response.json(); + const result = data.result || data; + const modeData = result.data || result; + + // Process messages from polling response + if (modeData.messages && Array.isArray(modeData.messages)) { + const newMsgs = modeData.messages.slice(lastPagerMsgCount); + newMsgs.forEach(msg => { + // Convert to expected format + const displayMsg = { + type: 'message', + protocol: msg.protocol || 'UNKNOWN', + address: msg.address || '', + function: msg.function || '', + msg_type: msg.msg_type || 'Alpha', + message: msg.message || '', + timestamp: msg.received_at || new Date().toISOString(), + agent_name: result.agent_name || 'Remote Agent' + }; + addMessage(displayMsg); + }); + lastPagerMsgCount = modeData.messages.length; + } + } catch (err) { + console.error('Pager polling error:', err); + } + }, pollInterval); } function addMessage(msg) { @@ -7084,12 +7299,20 @@ } } + // APRS mode polling timer for agent mode + let aprsPollTimer = null; + let aprsCurrentAgent = null; + function startAprs() { // Get values from function bar controls const region = document.getElementById('aprsStripRegion').value; const device = getSelectedDevice(); const gain = document.getElementById('aprsStripGain').value; + // Check if using agent mode + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + aprsCurrentAgent = isAgentMode ? currentAgent : null; + // Build request body const requestBody = { region, @@ -7107,14 +7330,22 @@ requestBody.frequency = customFreq; } - fetch('/aprs/start', { + // Determine endpoint based on agent mode + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/aprs/start` + : '/aprs/start'; + + fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }) .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') { isAprsRunning = true; aprsPacketCount = 0; aprsStationCount = 0; @@ -7138,7 +7369,7 @@ document.getElementById('aprsMapStatus').textContent = 'TRACKING'; document.getElementById('aprsMapStatus').style.color = 'var(--accent-green)'; // Update function bar status - updateAprsStatus('listening', data.frequency); + updateAprsStatus('listening', scanResult.frequency); // Reset function bar stats document.getElementById('aprsStripStations').textContent = '0'; document.getElementById('aprsStripPackets').textContent = '0'; @@ -7149,9 +7380,9 @@ const customFreqInput = document.getElementById('aprsStripCustomFreq'); if (customFreqInput) customFreqInput.disabled = true; startAprsMeterCheck(); - startAprsStream(); + startAprsStream(isAgentMode); } else { - alert('APRS Error: ' + data.message); + alert('APRS Error: ' + (scanResult.message || scanResult.error || 'Failed to start')); updateAprsStatus('error'); } }) @@ -7162,10 +7393,16 @@ } function stopAprs() { - fetch('/aprs/stop', { method: 'POST' }) + const isAgentMode = aprsCurrentAgent !== null; + const endpoint = isAgentMode + ? `/controller/agents/${aprsCurrentAgent}/aprs/stop` + : '/aprs/stop'; + + fetch(endpoint, { method: 'POST' }) .then(r => r.json()) .then(data => { isAprsRunning = false; + aprsCurrentAgent = null; // Update function bar buttons document.getElementById('aprsStripStartBtn').style.display = 'inline-block'; document.getElementById('aprsStripStopBtn').style.display = 'none'; @@ -7192,29 +7429,60 @@ aprsEventSource.close(); aprsEventSource = null; } + // Clear polling timer + if (aprsPollTimer) { + clearInterval(aprsPollTimer); + aprsPollTimer = null; + } }); } - function startAprsStream() { + function startAprsStream(isAgentMode = false) { if (aprsEventSource) aprsEventSource.close(); - aprsEventSource = new EventSource('/aprs/stream'); + + // Use different stream endpoint for agent mode + const streamUrl = isAgentMode ? '/controller/stream/all' : '/aprs/stream'; + aprsEventSource = new EventSource(streamUrl); aprsEventSource.onmessage = function (e) { const data = JSON.parse(e.data); - if (data.type === 'aprs') { - aprsPacketCount++; - // Update map footer and function bar - document.getElementById('aprsPacketCount').textContent = aprsPacketCount; - document.getElementById('aprsStripPackets').textContent = aprsPacketCount; - // Switch to tracking state on first packet - const dot = document.getElementById('aprsStripDot'); - if (dot && !dot.classList.contains('tracking')) { - updateAprsStatus('tracking'); + + if (isAgentMode) { + // Handle multi-agent stream format + if (data.scan_type === 'aprs' && data.payload) { + const payload = data.payload; + if (payload.type === 'aprs') { + aprsPacketCount++; + document.getElementById('aprsPacketCount').textContent = aprsPacketCount; + document.getElementById('aprsStripPackets').textContent = aprsPacketCount; + const dot = document.getElementById('aprsStripDot'); + if (dot && !dot.classList.contains('tracking')) { + updateAprsStatus('tracking'); + } + // Add agent info + payload.agent_name = data.agent_name; + processAprsPacket(payload); + } else if (payload.type === 'meter') { + updateAprsMeter(payload.level); + } + } + } else { + // Local stream format + if (data.type === 'aprs') { + aprsPacketCount++; + // Update map footer and function bar + document.getElementById('aprsPacketCount').textContent = aprsPacketCount; + document.getElementById('aprsStripPackets').textContent = aprsPacketCount; + // Switch to tracking state on first packet + const dot = document.getElementById('aprsStripDot'); + if (dot && !dot.classList.contains('tracking')) { + updateAprsStatus('tracking'); + } + processAprsPacket(data); + } else if (data.type === 'meter') { + // Update signal indicator in function bar + updateAprsMeter(data.level); } - processAprsPacket(data); - } else if (data.type === 'meter') { - // Update signal indicator in function bar - updateAprsMeter(data.level); } }; @@ -7222,6 +7490,61 @@ console.error('APRS stream error'); updateAprsStatus('error'); }; + + // Start polling fallback for agent mode + if (isAgentMode) { + startAprsPolling(); + } + } + + // Track last station count for polling + let lastAprsStationCount = 0; + + function startAprsPolling() { + if (aprsPollTimer) return; + lastAprsStationCount = 0; + + const pollInterval = 2000; + aprsPollTimer = setInterval(async () => { + if (!isAprsRunning || !aprsCurrentAgent) { + clearInterval(aprsPollTimer); + aprsPollTimer = null; + return; + } + + try { + const response = await fetch(`/controller/agents/${aprsCurrentAgent}/aprs/data`); + if (!response.ok) return; + + const data = await response.json(); + const result = data.result || data; + const stations = result.data || []; + + // Process new stations + if (stations.length > lastAprsStationCount) { + const newStations = stations.slice(lastAprsStationCount); + newStations.forEach(station => { + aprsPacketCount++; + document.getElementById('aprsPacketCount').textContent = aprsPacketCount; + document.getElementById('aprsStripPackets').textContent = aprsPacketCount; + const dot = document.getElementById('aprsStripDot'); + if (dot && !dot.classList.contains('tracking')) { + updateAprsStatus('tracking'); + } + // Convert to expected packet format + const packet = { + type: 'aprs', + ...station, + agent_name: result.agent_name || 'Remote Agent' + }; + processAprsPacket(packet); + }); + lastAprsStationCount = stations.length; + } + } catch (err) { + console.error('APRS polling error:', err); + } + }, pollInterval); } // Signal Meter Functions @@ -7710,6 +8033,9 @@ } } + // Satellite mode agent state + let satelliteCurrentAgent = null; + function calculatePasses() { const lat = parseFloat(document.getElementById('obsLat').value); const lon = parseFloat(document.getElementById('obsLon').value); @@ -7723,18 +8049,30 @@ return; } - fetch('/satellite/predict', { + // Check if using agent mode + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + satelliteCurrentAgent = isAgentMode ? currentAgent : null; + + // Determine endpoint based on agent mode + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/satellite/predict` + : '/satellite/predict'; + + fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat, lon, hours, minEl, satellites }) }) .then(r => r.json()) .then(data => { - if (data.status === 'success') { - satellitePasses = data.passes; + // Handle controller proxy response format + const result = isAgentMode && data.result ? data.result : data; + + if (result.status === 'success') { + satellitePasses = result.passes; renderPassList(); - document.getElementById('passCount').textContent = data.passes.length; - if (data.passes.length > 0) { + document.getElementById('passCount').textContent = result.passes.length; + if (result.passes.length > 0) { selectPass(0); document.getElementById('satelliteCountdown').style.display = 'block'; updateSatelliteCountdown(); @@ -7743,7 +8081,7 @@ document.getElementById('satelliteCountdown').style.display = 'none'; } } else { - alert('Error: ' + data.message); + alert('Error: ' + (result.message || result.error || 'Failed to predict passes')); } }); } @@ -7941,15 +8279,24 @@ const lat = parseFloat(document.getElementById('obsLat').value); const lon = parseFloat(document.getElementById('obsLon').value); - fetch('/satellite/position', { + // Check if using agent mode + const isAgentMode = satelliteCurrentAgent !== null; + const endpoint = isAgentMode + ? `/controller/agents/${satelliteCurrentAgent}/satellite/position` + : '/satellite/position'; + + fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat, lon, satellites, includeTrack: true }) }) .then(r => r.json()) .then(data => { - if (data.status === 'success' && data.positions) { - updateRealTimeIndicators(data.positions); + // Handle controller proxy response format + const result = isAgentMode && data.result ? data.result : data; + + if (result.status === 'success' && result.positions) { + updateRealTimeIndicators(result.positions); } }); } @@ -8350,10 +8697,33 @@ async function refreshTscmDevices() { // Fetch available interfaces for TSCM scanning + // Check if agent is selected and route accordingly try { - const response = await fetch('/tscm/devices'); + let response; + if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') { + // Fetch devices from agent capabilities + response = await fetch(`/controller/agents/${currentAgent}?refresh=true`); + } else { + response = await fetch('/tscm/devices'); + } const data = await response.json(); - const devices = data.devices || {}; + + // Handle both local (/tscm/devices) and agent response formats + let devices; + const isAgentResponse = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + + if (isAgentResponse && data.agent) { + // Agent response format - extract from capabilities/interfaces + const agentInterfaces = data.agent.interfaces || {}; + const agentCapabilities = data.agent.capabilities || {}; + devices = { + wifi_interfaces: agentInterfaces.wifi_interfaces || [], + bt_adapters: agentInterfaces.bt_adapters || [], + sdr_devices: agentCapabilities.devices || agentInterfaces.sdr_devices || [] + }; + } else { + devices = data.devices || {}; + } // Populate WiFi interfaces const wifiSelect = document.getElementById('tscmWifiInterface'); @@ -8370,7 +8740,11 @@ wifiSelect.value = devices.wifi_interfaces[0].name; } } else { - wifiSelect.innerHTML = ''; + if (isAgentResponse) { + wifiSelect.innerHTML = ''; + } else { + wifiSelect.innerHTML = ''; + } } // Populate Bluetooth adapters @@ -8388,7 +8762,11 @@ btSelect.value = devices.bt_adapters[0].name; } } else { - btSelect.innerHTML = ''; + if (isAgentResponse) { + btSelect.innerHTML = ''; + } else { + btSelect.innerHTML = ''; + } } // Populate SDR devices @@ -8397,16 +8775,20 @@ if (devices.sdr_devices && devices.sdr_devices.length > 0) { devices.sdr_devices.forEach(dev => { const opt = document.createElement('option'); - opt.value = dev.index; + opt.value = dev.index !== undefined ? dev.index : 0; opt.textContent = dev.display_name || dev.name || 'SDR Device'; sdrSelect.appendChild(opt); }); // Auto-select first SDR if available if (devices.sdr_devices.length > 0) { - sdrSelect.value = devices.sdr_devices[0].index; + sdrSelect.value = devices.sdr_devices[0].index !== undefined ? devices.sdr_devices[0].index : 0; } } else { - sdrSelect.innerHTML = ''; + if (isAgentResponse) { + sdrSelect.innerHTML = ''; + } else { + sdrSelect.innerHTML = ''; + } } // Show warnings (e.g., not running as root) @@ -8465,8 +8847,23 @@ document.getElementById('tscmDeviceWarnings').style.display = 'none'; document.getElementById('tscmDeviceWarnings').innerHTML = ''; + // Check for agent mode + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + + // Check for conflicts if using agent + if (isAgentMode && typeof checkAgentModeConflict === 'function') { + if (!checkAgentModeConflict('tscm')) { + return; // Conflict detected, user cancelled + } + } + try { - const response = await fetch('/tscm/sweep/start', { + // Route to agent or local based on selection + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/tscm/start` + : '/tscm/sweep/start'; + + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -8483,7 +8880,9 @@ }); const data = await response.json(); - if (data.status === 'success') { + // Handle controller proxy response (agent response is nested in 'result') + const scanResult = isAgentMode && data.result ? data.result : data; + if (scanResult.status === 'success' || scanResult.status === 'started') { isTscmRunning = true; tscmSweepStartTime = new Date(); tscmSweepEndTime = null; @@ -8496,16 +8895,16 @@ document.getElementById('tscmReportBtn').style.display = 'none'; // Show warnings if any devices unavailable - if (data.warnings && data.warnings.length > 0) { + if (scanResult.warnings && scanResult.warnings.length > 0) { const warningsDiv = document.getElementById('tscmDeviceWarnings'); - warningsDiv.innerHTML = data.warnings.map(w => + warningsDiv.innerHTML = scanResult.warnings.map(w => `
⚠ ${w}
` ).join(''); warningsDiv.style.display = 'block'; } // Update device indicators - updateTscmDeviceIndicators(data.devices); + updateTscmDeviceIndicators(scanResult.devices); // Reset displays tscmThreats = []; @@ -8519,9 +8918,9 @@ startTscmStream(); } else { // Show error with details - let errorMsg = data.message || 'Failed to start sweep'; - if (data.details && data.details.length > 0) { - errorMsg += '\n\n' + data.details.join('\n'); + let errorMsg = scanResult.message || 'Failed to start sweep'; + if (scanResult.details && scanResult.details.length > 0) { + errorMsg += '\n\n' + scanResult.details.join('\n'); } alert(errorMsg); } @@ -8552,7 +8951,12 @@ async function stopTscmSweep() { try { - await fetch('/tscm/sweep/stop', { method: 'POST' }); + // Route to agent or local based on selection + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const endpoint = isAgentMode + ? `/controller/agents/${currentAgent}/tscm/stop` + : '/tscm/sweep/stop'; + await fetch(endpoint, { method: 'POST' }); } catch (e) { console.error('Error stopping sweep:', e); } @@ -9111,12 +9515,33 @@ tscmEventSource.close(); } - tscmEventSource = new EventSource('/tscm/sweep/stream'); + // Check if using agent - connect to multi-agent stream + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const streamUrl = isAgentMode + ? '/controller/stream/all' + : '/tscm/sweep/stream'; + + tscmEventSource = new EventSource(streamUrl); tscmEventSource.onmessage = function (event) { try { const data = JSON.parse(event.data); - handleTscmEvent(data); + + // If using multi-agent stream, filter for TSCM data + if (isAgentMode) { + if (data.scan_type === 'tscm' || data.type?.startsWith('tscm') || + data.type === 'wifi_device' || data.type === 'bt_device' || + data.type === 'rf_signal' || data.type === 'threat' || + data.type === 'sweep_progress') { + // Add agent info to data for display + if (data.agent_name) { + data._agent = data.agent_name; + } + handleTscmEvent(data.payload || data); + } + } else { + handleTscmEvent(data); + } } catch (e) { console.error('TSCM SSE parse error:', e); } 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 @@
+ +