Compare commits

..

24 Commits

Author SHA1 Message Date
Smittix f795180c7d Release v2.12.1
Bug fixes and improvements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:16:12 +00:00
Smittix d1f1ce1f4b Add SDR device status panel and ADS-B Bias-T toggle
- Add /devices/status endpoint showing which SDR is in use and by what mode
- Add real-time status panel on main dashboard with 5s auto-refresh
- Add Bias-T toggle to ADS-B dashboard with localStorage persistence
- Auto-detect correct dump1090 bias-t flag (--enable-biast vs unsupported)
- Standardize SDR device labels across all pages

Closes #102

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:36:27 +00:00
Smittix 334073089f Fix SDR device type not synced on page refresh
Initialize currentDeviceList from server-provided deviceList on page load
and auto-select the correct hardware type dropdown value. Previously the
device list was empty until "Refresh Devices" was clicked, causing the
hardware type dropdown to show incorrect values.

Fixes #99

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:26:23 +00:00
Smittix df634dc741 Fix Meshtastic connection type not restored on page refresh
Pass connection_type to updateConnectionUI() in checkStatus() so TCP
connections display correctly after browser refresh instead of defaulting
to Serial.

Fixes #98

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:23:56 +00:00
Smittix a76dfde02d Add SDR device registry to prevent decoder conflicts
Implements centralized tracking of SDR device allocation to prevent
multiple decoders from trying to use the same device simultaneously.

- Add sdr_device_registry with claim/release/status functions in app.py
- Update all SDR-based routes to claim devices on start and release on stop
- Return HTTP 409 with DEVICE_BUSY error when device is already in use
- Clear registry on /killall
- Skip device claims for remote connections (rtl_tcp, remote SBS)

Fixes #100
Fixes #101

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:05:21 +00:00
Smittix 36f8349bc7 Merge pull request #96 from alphafox02/fix-agent-bugs
Fix agent mode issues and WiFi deep scan polling
2026-02-01 12:57:02 +00:00
cemaxecuter 130a3a2d8e Don't stop agent scans when switching agents
When switching between agents in the UI, only stop the UI polling -
don't send a stop command to the agent. Agent scans should continue
running independently. When switching back, checkScanStatus() will
detect the running scan and resume polling.
2026-01-31 08:59:30 -05:00
cemaxecuter bd6fa27970 Detect existing monitor mode when loading agent interfaces
When refreshing agent WiFi interfaces, check if any interface has
type='monitor' and automatically set the monitor status to Active.
Previously the UI only showed Active when monitor was explicitly
enabled via the button.
2026-01-31 08:55:05 -05:00
cemaxecuter 630bc2971a Fix WiFi deep scan polling on agent - normalize scan_type value
Agent returns scan_type 'deepscan' but UI expected 'deep', causing the
polling to immediately stop when checking scan status on agent switch.
Now normalizes 'deepscan' to 'deep' in checkScanStatus.
2026-01-31 08:51:17 -05:00
cemaxecuter 7182f7803a Auto-refresh agent capabilities after monitor mode toggle
When monitor mode is toggled on a remote agent, the controller now
automatically refreshes the agent's capabilities and updates the
database. This keeps the UI interface list in sync without requiring
a manual refresh.
2026-01-31 08:48:32 -05:00
cemaxecuter a64a7c414c Invalidate capabilities cache after monitor mode toggle
After enabling/disabling monitor mode, clear the cached capabilities
so the next refresh shows the updated interface list (e.g., wlo1mon
instead of wlo1).
2026-01-31 08:17:07 -05:00
cemaxecuter f0cc396a6b Fix agent mode issues and WiFi deep scan polling
Agent fixes:
- Fix Ctrl+C hang by running cleanup in background thread
- Add force-exit on double Ctrl+C
- Improve exception handling in output reader threads to prevent
  bad file descriptor errors on shutdown
- Reduce cleanup timeouts for faster shutdown

Controller/UI fixes:
- Add URL validation for agent registration (check port, protocol)
- Show helpful message when agent is unreachable during registration
- Clarify API key field label (reserved for future use)
- Add client-side URL validation with user-friendly error messages

WiFi agent mode fixes:
- Add polling fallback for deep scan when push mode is disabled
- Polls /controller/agents/{id}/wifi/data every 2 seconds
- Detect running scans when switching to an agent
- Fix scan_mode detection (agent uses params.scan_type)
2026-01-31 08:10:32 -05:00
Smittix 5f588a5513 fix: Auto-detect RTL-SDR drivers and blacklist instead of prompting
- Skip RTL-SDR Blog driver prompt if rtl_test already exists
- Skip DVB blacklist prompt if blacklist file already exists
- Only prompt user when configuration is actually needed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:08:08 +00:00
Smittix 599df7734b fix: Use Makefile instead of CMake for slowrx build
slowrx uses a simple Makefile, not CMake. Remove unnecessary cmake
dependency and fix the build process.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:02:07 +00:00
Smittix 49fa02142d feat: Add TCP connection support for Meshtastic
Allow connecting to WiFi-enabled Meshtastic devices via TCP/IP in
addition to USB/Serial connections. This enables remote monitoring
of mesh nodes that have WiFi capability (T-Beam, Heltec WiFi LoRa, etc).

- Add connection_type parameter ('serial' or 'tcp') to /meshtastic/start
- Add hostname parameter for TCP connections
- Update UI with connection type dropdown and hostname input field
- Show connection type in status responses

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:01:46 +00:00
Smittix 333dc00ee2 fix: Show build errors and add pkg-config for slowrx source builds
- Add pkg-config dependency for cmake to locate libraries
- Display cmake/make error output (last 20 lines) on failure
- Helps users troubleshoot slowrx build failures on Debian/Ubuntu/macOS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:55:28 +00:00
Smittix 2bc71e44ad Merge branch 'upstream-shared-observer-location' 2026-01-30 22:52:17 +00:00
Smittix 92265da5fb fix: Add slowrx source build fallback for Debian/Ubuntu
If slowrx is not available via apt, build from source with required
dependencies (libfftw3-dev, libsndfile1-dev, libgtk-3-dev, libasound2-dev,
libpulse-dev).

Matches the existing fallback pattern used for macOS.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:43:39 +00:00
Smittix 9c1516c086 feat: Add real-time Doppler tracking for ISS SSTV reception
- Add DopplerTracker class using skyfield for satellite tracking
- Calculate and apply Doppler shift correction (up to ±3.5 kHz at 145.800 MHz)
- Background thread monitors shift and retunes rtl_fm when >500 Hz drift
- New /sstv/doppler endpoint for real-time Doppler info
- Start endpoint accepts latitude/longitude for automatic tracking

Also:
- Add slowrx installation to setup.sh (source build for macOS, apt for Debian)
- Sync observer location to dashboard-specific localStorage keys

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:40:27 +00:00
Smittix cd7940bdc2 fix: Add TPMS pressure field mappings for 433MHz sensor display
The sensor field mapping only handled pressure_hPa (weather station
barometric pressure), causing TPMS tire pressure data to not display.

Added mappings for TPMS-specific rtl_433 field names:
- pressure_PSI (common in US TPMS sensors)
- pressure_kPa
- tire_pressure_kPa
- flags/state (tire state indicators)

Fixes #95

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:39:01 +00:00
James Ward 4a5f3e1802 docs: document shared location and auto-start env vars 2026-01-30 10:55:01 -08:00
James Ward 1b5bf4c061 fix: make ADS-B auto-start opt-in 2026-01-30 10:51:35 -08:00
James Ward 384d02649a feat: add shared observer location with opt-out 2026-01-30 10:49:53 -08:00
Smittix d51da40a67 Refactor settings modal HTML structure 2026-01-30 17:12:28 +00:00
46 changed files with 3782 additions and 387 deletions
+41
View File
@@ -74,6 +74,47 @@ The ADS-B history feature persists aircraft messages to Postgres for long-term a
docker compose --profile history up -d docker compose --profile history up -d
``` ```
Set the following environment variables (for example in a `.env` file):
```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true
INTERCEPT_ADSB_DB_HOST=adsb_db
INTERCEPT_ADSB_DB_PORT=5432
INTERCEPT_ADSB_DB_NAME=intercept_adsb
INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept
```
### Other ADS-B Settings
Set these as environment variables for either local installs or Docker:
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
Then open **/adsb/history** for the reporting dashboard. Then open **/adsb/history** for the reporting dashboard.
### Open the Interface ### Open the Interface
+85 -2
View File
@@ -27,7 +27,7 @@ from typing import Any
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes from utils.process import cleanup_stale_processes
from utils.sdr import SDRFactory from utils.sdr import SDRFactory
@@ -38,6 +38,7 @@ from utils.constants import (
MAX_BT_DEVICE_AGE_SECONDS, MAX_BT_DEVICE_AGE_SECONDS,
MAX_VESSEL_AGE_SECONDS, MAX_VESSEL_AGE_SECONDS,
MAX_DSC_MESSAGE_AGE_SECONDS, MAX_DSC_MESSAGE_AGE_SECONDS,
MAX_DEAUTH_ALERTS_AGE_SECONDS,
QUEUE_MAX_SIZE, QUEUE_MAX_SIZE,
) )
import logging import logging
@@ -175,6 +176,11 @@ dsc_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock() tscm_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
deauth_detector_lock = threading.Lock()
# ============================================ # ============================================
# GLOBAL STATE DICTIONARIES # GLOBAL STATE DICTIONARIES
# ============================================ # ============================================
@@ -204,6 +210,9 @@ ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessel
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup # DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages') dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
# Deauth alerts - using DataStore for automatic cleanup
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
# Satellite state # Satellite state
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated) satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
@@ -215,6 +224,53 @@ cleanup_manager.register(bt_beacons)
cleanup_manager.register(adsb_aircraft) cleanup_manager.register(adsb_aircraft)
cleanup_manager.register(ais_vessels) cleanup_manager.register(ais_vessels)
cleanup_manager.register(dsc_messages) cleanup_manager.register(dsc_messages)
cleanup_manager.register(deauth_alerts)
# ============================================
# SDR DEVICE REGISTRY
# ============================================
# Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: mode_name (str)
sdr_device_registry: dict[int, str] = {}
sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
"""Claim an SDR device for a mode.
Args:
device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
Returns:
Error message if device is in use, None if successfully claimed
"""
with sdr_device_registry_lock:
if device_index in sdr_device_registry:
in_use_by = sdr_device_registry[device_index]
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
sdr_device_registry[device_index] = mode_name
return None
def release_sdr_device(device_index: int) -> None:
"""Release an SDR device from the registry.
Args:
device_index: The SDR device index to release
"""
with sdr_device_registry_lock:
sdr_device_registry.pop(device_index, None)
def get_sdr_device_status() -> dict[int, str]:
"""Get current SDR device allocations.
Returns:
Dictionary mapping device indices to mode names
"""
with sdr_device_registry_lock:
return dict(sdr_device_registry)
# ============================================ # ============================================
@@ -279,7 +335,14 @@ def index() -> str:
'rtlamr': check_tool('rtlamr') 'rtlamr': check_tool('rtlamr')
} }
devices = [d.to_dict() for d in SDRFactory.detect_devices()] devices = [d.to_dict() for d in SDRFactory.detect_devices()]
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG) return render_template(
'index.html',
tools=tools,
devices=devices,
version=VERSION,
changelog=CHANGELOG,
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
@app.route('/favicon.svg') @app.route('/favicon.svg')
@@ -294,6 +357,22 @@ def get_devices() -> Response:
return jsonify([d.to_dict() for d in devices]) return jsonify([d.to_dict() for d in devices])
@app.route('/devices/status')
def get_devices_status() -> Response:
"""Get all SDR devices with usage status."""
devices = SDRFactory.detect_devices()
registry = get_sdr_device_status()
result = []
for device in devices:
d = device.to_dict()
d['in_use'] = device.index in registry
d['used_by'] = registry.get(device.index)
result.append(d)
return jsonify(result)
@app.route('/devices/debug') @app.route('/devices/debug')
def get_devices_debug() -> Response: def get_devices_debug() -> Response:
"""Get detailed SDR device detection diagnostics.""" """Get detailed SDR device detection diagnostics."""
@@ -622,6 +701,10 @@ def kill_all() -> Response:
dsc_process = None dsc_process = None
dsc_rtl_process = None dsc_rtl_process = None
# Clear SDR device registry
with sdr_device_registry_lock:
sdr_device_registry.clear()
return jsonify({'status': 'killed', 'processes': killed}) return jsonify({'status': 'killed', 'processes': killed})
+12 -1
View File
@@ -7,10 +7,17 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.12.0" VERSION = "2.12.1"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{
"version": "2.12.1",
"date": "February 2026",
"highlights": [
"Bug fixes and improvements",
]
},
{ {
"version": "2.12.0", "version": "2.12.0",
"date": "January 2026", "date": "January 2026",
@@ -139,6 +146,7 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
# ADS-B settings # ADS-B settings
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003) ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0) ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
ADSB_AUTO_START = _get_env_bool('ADSB_AUTO_START', False)
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False) ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost') ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432) ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
@@ -149,6 +157,9 @@ ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0) ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000) ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
# Observer location settings
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
# Satellite settings # Satellite settings
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30) SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30) SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
+8
View File
@@ -36,6 +36,10 @@ services:
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb # - INTERCEPT_ADSB_DB_NAME=intercept_adsb
# - INTERCEPT_ADSB_DB_USER=intercept # - INTERCEPT_ADSB_DB_USER=intercept
# - INTERCEPT_ADSB_DB_PASSWORD=intercept # - INTERCEPT_ADSB_DB_PASSWORD=intercept
# ADS-B auto-start on dashboard load (default false)
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
# Network mode for WiFi scanning (requires host network) # Network mode for WiFi scanning (requires host network)
# network_mode: host # network_mode: host
restart: unless-stopped restart: unless-stopped
@@ -68,6 +72,10 @@ services:
- INTERCEPT_ADSB_DB_NAME=intercept_adsb - INTERCEPT_ADSB_DB_NAME=intercept_adsb
- INTERCEPT_ADSB_DB_USER=intercept - INTERCEPT_ADSB_DB_USER=intercept
- INTERCEPT_ADSB_DB_PASSWORD=intercept - INTERCEPT_ADSB_DB_PASSWORD=intercept
# ADS-B auto-start on dashboard load (default false)
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"] test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
+34 -1
View File
@@ -65,6 +65,8 @@ INTERCEPT automatically detects known trackers:
- **Manual Entry** - Type coordinates directly - **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS) - **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates - **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception 4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map 5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information 6. **Click Aircraft** - Click markers for detailed information
@@ -72,6 +74,9 @@ INTERCEPT automatically detects known trackers:
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only 8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view 9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
### Emergency Squawks ### Emergency Squawks
The system highlights aircraft transmitting emergency squawks: The system highlights aircraft transmitting emergency squawks:
@@ -96,12 +101,40 @@ Set the following environment variables (Docker recommended):
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user | | `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password | | `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Other ADS-B Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
### Docker Setup ### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage: `docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash ```bash
docker compose up -d docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
``` ```
### Using the History Dashboard ### Using the History Dashboard
+279 -41
View File
@@ -872,6 +872,150 @@ class ModeManager:
return data return data
# =========================================================================
# WiFi Monitor Mode
# =========================================================================
def toggle_monitor_mode(self, params: dict) -> dict:
"""Enable or disable monitor mode on a WiFi interface."""
import re
action = params.get('action', 'start')
interface = params.get('interface', '')
kill_processes = params.get('kill_processes', False)
# Validate interface name (alphanumeric, underscore, dash only)
if not interface or not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', interface):
return {'status': 'error', 'message': 'Invalid interface name'}
airmon_path = self._get_tool_path('airmon-ng')
iw_path = self._get_tool_path('iw')
if action == 'start':
if airmon_path:
try:
# Get interfaces before
def get_wireless_interfaces():
interfaces = set()
try:
for iface in os.listdir('/sys/class/net'):
if os.path.exists(f'/sys/class/net/{iface}/wireless') or 'mon' in iface:
interfaces.add(iface)
except OSError:
pass
return interfaces
interfaces_before = get_wireless_interfaces()
# Kill interfering processes if requested
if kill_processes:
subprocess.run([airmon_path, 'check', 'kill'],
capture_output=True, timeout=10)
# Start monitor mode
result = subprocess.run([airmon_path, 'start', interface],
capture_output=True, text=True, timeout=15)
output = result.stdout + result.stderr
time.sleep(1)
interfaces_after = get_wireless_interfaces()
# Find the new monitor interface
new_interfaces = interfaces_after - interfaces_before
monitor_iface = None
if new_interfaces:
for iface in new_interfaces:
if 'mon' in iface:
monitor_iface = iface
break
if not monitor_iface:
monitor_iface = list(new_interfaces)[0]
# Try to parse from airmon-ng output
if not monitor_iface:
patterns = [
r'\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b',
r'\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)',
r'enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)',
]
for pattern in patterns:
match = re.search(pattern, output, re.IGNORECASE)
if match:
candidate = match.group(1)
if candidate and not candidate[0].isdigit():
monitor_iface = candidate
break
# Fallback: check if original interface is in monitor mode
if not monitor_iface:
try:
result = subprocess.run(['iwconfig', interface],
capture_output=True, text=True, timeout=5)
if 'Mode:Monitor' in result.stdout:
monitor_iface = interface
except (subprocess.SubprocessError, OSError):
pass
# Last resort: try common naming
if not monitor_iface:
potential = interface + 'mon'
if os.path.exists(f'/sys/class/net/{potential}'):
monitor_iface = potential
if not monitor_iface or not os.path.exists(f'/sys/class/net/{monitor_iface}'):
all_wireless = list(get_wireless_interfaces())
return {
'status': 'error',
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
}
self.wifi_monitor_interface = monitor_iface
self._capabilities = None # Invalidate cache so interfaces refresh
logger.info(f"Monitor mode enabled on {monitor_iface}")
return {'status': 'success', 'monitor_interface': monitor_iface}
except Exception as e:
logger.error(f"Error enabling monitor mode: {e}")
return {'status': 'error', 'message': str(e)}
elif iw_path:
try:
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
subprocess.run([iw_path, interface, 'set', 'monitor', 'control'], capture_output=True)
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
self.wifi_monitor_interface = interface
self._capabilities = None # Invalidate cache
return {'status': 'success', 'monitor_interface': interface}
except Exception as e:
return {'status': 'error', 'message': str(e)}
else:
return {'status': 'error', 'message': 'No monitor mode tools available (airmon-ng or iw)'}
else: # stop
current_iface = getattr(self, 'wifi_monitor_interface', None) or interface
if airmon_path:
try:
subprocess.run([airmon_path, 'stop', current_iface],
capture_output=True, text=True, timeout=15)
self.wifi_monitor_interface = None
self._capabilities = None # Invalidate cache
return {'status': 'success', 'message': 'Monitor mode disabled'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
elif iw_path:
try:
subprocess.run(['ip', 'link', 'set', current_iface, 'down'], capture_output=True)
subprocess.run([iw_path, current_iface, 'set', 'type', 'managed'], capture_output=True)
subprocess.run(['ip', 'link', 'set', current_iface, 'up'], capture_output=True)
self.wifi_monitor_interface = None
self._capabilities = None # Invalidate cache
return {'status': 'success', 'message': 'Monitor mode disabled'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
return {'status': 'error', 'message': 'Unknown action'}
# ========================================================================= # =========================================================================
# Mode-specific implementations # Mode-specific implementations
# ========================================================================= # =========================================================================
@@ -914,26 +1058,34 @@ class ModeManager:
"""Internal mode stop - terminates processes and cleans up.""" """Internal mode stop - terminates processes and cleans up."""
logger.info(f"Stopping mode {mode}") logger.info(f"Stopping mode {mode}")
# Signal stop # Signal stop first - this unblocks any waiting threads
if mode in self.stop_events: if mode in self.stop_events:
self.stop_events[mode].set() self.stop_events[mode].set()
# Terminate process if running # Terminate process if running
if mode in self.processes: if mode in self.processes:
proc = self.processes[mode] proc = self.processes[mode]
if proc and proc.poll() is None: try:
proc.terminate() if proc and proc.poll() is None:
try: proc.terminate()
proc.wait(timeout=3) try:
except subprocess.TimeoutExpired: proc.wait(timeout=2)
proc.kill() except subprocess.TimeoutExpired:
proc.kill()
try:
proc.wait(timeout=1)
except Exception:
pass
except (OSError, ProcessLookupError) as e:
# Process already dead or inaccessible
logger.debug(f"Process cleanup for {mode}: {e}")
del self.processes[mode] del self.processes[mode]
# Wait for output thread # Wait for output thread (short timeout since stop event is set)
if mode in self.output_threads: if mode in self.output_threads:
thread = self.output_threads[mode] thread = self.output_threads[mode]
if thread and thread.is_alive(): if thread and thread.is_alive():
thread.join(timeout=2) thread.join(timeout=1)
del self.output_threads[mode] del self.output_threads[mode]
# Clean up # Clean up
@@ -1137,10 +1289,16 @@ class ModeManager:
except json.JSONDecodeError: except json.JSONDecodeError:
pass # Not JSON, ignore pass # Not JSON, ignore
except (OSError, ValueError) as e:
# Bad file descriptor or closed file - process was terminated
logger.debug(f"Sensor output reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"Sensor output reader error: {e}") logger.error(f"Sensor output reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
logger.info("Sensor output reader stopped") logger.info("Sensor output reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -2102,15 +2260,24 @@ class ModeManager:
logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}") logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}")
except (OSError, ValueError) as e:
# Bad file descriptor or closed file - process was terminated
logger.debug(f"Pager reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"Pager reader error: {e}") logger.error(f"Pager reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
if 'pager_rtl' in self.processes: if 'pager_rtl' in self.processes:
rtl_proc = self.processes['pager_rtl'] try:
if rtl_proc.poll() is None: rtl_proc = self.processes['pager_rtl']
rtl_proc.terminate() if rtl_proc.poll() is None:
del self.processes['pager_rtl'] rtl_proc.terminate()
del self.processes['pager_rtl']
except Exception:
pass
logger.info("Pager reader stopped") logger.info("Pager reader stopped")
def _parse_pager_message(self, line: str) -> dict | None: def _parse_pager_message(self, line: str) -> dict | None:
@@ -2492,10 +2659,15 @@ class ModeManager:
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
except (OSError, ValueError) as e:
logger.debug(f"ACARS reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"ACARS reader error: {e}") logger.error(f"ACARS reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
logger.info("ACARS reader stopped") logger.info("ACARS reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -2632,15 +2804,23 @@ class ModeManager:
logger.debug(f"APRS: {callsign}") logger.debug(f"APRS: {callsign}")
except (OSError, ValueError) as e:
logger.debug(f"APRS reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"APRS reader error: {e}") logger.error(f"APRS reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
if 'aprs_rtl' in self.processes: if 'aprs_rtl' in self.processes:
rtl_proc = self.processes['aprs_rtl'] try:
if rtl_proc.poll() is None: rtl_proc = self.processes['aprs_rtl']
rtl_proc.terminate() if rtl_proc.poll() is None:
del self.processes['aprs_rtl'] rtl_proc.terminate()
del self.processes['aprs_rtl']
except Exception:
pass
logger.info("APRS reader stopped") logger.info("APRS reader stopped")
def _parse_aprs_packet(self, line: str) -> dict | None: def _parse_aprs_packet(self, line: str) -> dict | None:
@@ -2788,15 +2968,23 @@ class ModeManager:
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
except (OSError, ValueError) as e:
logger.debug(f"RTLAMR reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"RTLAMR reader error: {e}") logger.error(f"RTLAMR reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
if 'rtlamr_tcp' in self.processes: if 'rtlamr_tcp' in self.processes:
tcp_proc = self.processes['rtlamr_tcp'] try:
if tcp_proc.poll() is None: tcp_proc = self.processes['rtlamr_tcp']
tcp_proc.terminate() if tcp_proc.poll() is None:
del self.processes['rtlamr_tcp'] tcp_proc.terminate()
del self.processes['rtlamr_tcp']
except Exception:
pass
logger.info("RTLAMR reader stopped") logger.info("RTLAMR reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -2901,10 +3089,15 @@ class ModeManager:
except ImportError: except ImportError:
logger.warning("DSCDecoder not available (missing scipy/numpy)") logger.warning("DSCDecoder not available (missing scipy/numpy)")
except (OSError, ValueError) as e:
logger.debug(f"DSC reader stopped: {e}")
except Exception as e: except Exception as e:
logger.error(f"DSC reader error: {e}") logger.error(f"DSC reader error: {e}")
finally: finally:
proc.wait() try:
proc.wait(timeout=1)
except Exception:
pass
logger.info("DSC reader stopped") logger.info("DSC reader stopped")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -3629,6 +3822,12 @@ class InterceptAgentHandler(BaseHTTPRequestHandler):
config.push_interval = int(body['push_interval']) config.push_interval = int(body['push_interval'])
self._send_json({'status': 'updated', 'config': config.to_dict()}) self._send_json({'status': 'updated', 'config': config.to_dict()})
elif path == '/wifi/monitor':
# Enable/disable monitor mode on WiFi interface
result = mode_manager.toggle_monitor_mode(body)
status = 200 if result.get('status') == 'success' else 400
self._send_json(result, status)
elif path.startswith('/') and path.count('/') == 2: elif path.startswith('/') and path.count('/') == 2:
# /{mode}/start or /{mode}/stop # /{mode}/start or /{mode}/stop
parts = path.split('/') parts = path.split('/')
@@ -3794,19 +3993,53 @@ def main():
print(" Press Ctrl+C to stop") print(" Press Ctrl+C to stop")
print() print()
# Handle shutdown # Shutdown flag
shutdown_requested = threading.Event()
# Handle shutdown - run cleanup in separate thread to avoid blocking
def signal_handler(sig, frame): def signal_handler(sig, frame):
if shutdown_requested.is_set():
# Already shutting down, force exit
print("\nForce exit...")
os._exit(1)
shutdown_requested.set()
print("\nShutting down...") print("\nShutting down...")
# Stop all running modes
for mode in list(mode_manager.running_modes.keys()): def cleanup():
mode_manager.stop_mode(mode) # Stop all running modes first (they have subprocesses)
if data_push_loop: for mode in list(mode_manager.running_modes.keys()):
data_push_loop.stop() try:
if push_client: mode_manager.stop_mode(mode)
push_client.stop() except Exception as e:
gps_manager.stop() logger.debug(f"Error stopping {mode}: {e}")
httpd.shutdown()
sys.exit(0) # Stop push services
if data_push_loop:
try:
data_push_loop.stop()
except Exception:
pass
if push_client:
try:
push_client.stop()
except Exception:
pass
# Stop GPS
try:
gps_manager.stop()
except Exception:
pass
# Shutdown HTTP server
try:
httpd.shutdown()
except Exception:
pass
# Run cleanup in background thread so signal handler returns quickly
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
cleanup_thread.start()
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
@@ -3815,9 +4048,14 @@ def main():
httpd.serve_forever() httpd.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: except Exception:
if push_client: pass
push_client.stop()
# Give cleanup thread time to finish
if shutdown_requested.is_set():
time.sleep(0.5)
print("Agent stopped.")
if __name__ == '__main__': if __name__ == '__main__':
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.12.0" version = "2.12.1"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
+3
View File
@@ -23,6 +23,9 @@ pyserial>=3.5
# Meshtastic mesh network support (optional - only needed for Meshtastic features) # Meshtastic mesh network support (optional - only needed for Meshtastic features)
meshtastic>=2.0.0 meshtastic>=2.0.0
# Deauthentication attack detection (optional - for WiFi TSCM)
scapy>=2.4.5
# QR code generation for Meshtastic channels (optional) # QR code generation for Meshtastic channels (optional)
qrcode[pil]>=7.4 qrcode[pil]>=7.4
+31 -2
View File
@@ -43,6 +43,9 @@ DEFAULT_ACARS_FREQUENCIES = [
acars_message_count = 0 acars_message_count = 0
acars_last_message_time = None acars_last_message_time = None
# Track which device is being used
acars_active_device: int | None = None
def find_acarsdec(): def find_acarsdec():
"""Find acarsdec binary.""" """Find acarsdec binary."""
@@ -175,7 +178,7 @@ def acars_status() -> Response:
@acars_bp.route('/start', methods=['POST']) @acars_bp.route('/start', methods=['POST'])
def start_acars() -> Response: def start_acars() -> Response:
"""Start ACARS decoder.""" """Start ACARS decoder."""
global acars_message_count, acars_last_message_time global acars_message_count, acars_last_message_time, acars_active_device
with app_module.acars_lock: with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None: if app_module.acars_process and app_module.acars_process.poll() is None:
@@ -202,6 +205,18 @@ def start_acars() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
acars_active_device = device_int
# Get frequencies - use provided or defaults # Get frequencies - use provided or defaults
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES) frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
if isinstance(frequencies, str): if isinstance(frequencies, str):
@@ -282,7 +297,10 @@ def start_acars() -> Response:
time.sleep(PROCESS_START_WAIT) time.sleep(PROCESS_START_WAIT)
if process.poll() is not None: if process.poll() is not None:
# Process died # Process died - release device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
stderr = '' stderr = ''
if process.stderr: if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace') stderr = process.stderr.read().decode('utf-8', errors='replace')
@@ -310,6 +328,10 @@ def start_acars() -> Response:
}) })
except Exception as e: except Exception as e:
# Release device on failure
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
logger.error(f"Failed to start ACARS decoder: {e}") logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -317,6 +339,8 @@ def start_acars() -> Response:
@acars_bp.route('/stop', methods=['POST']) @acars_bp.route('/stop', methods=['POST'])
def stop_acars() -> Response: def stop_acars() -> Response:
"""Stop ACARS decoder.""" """Stop ACARS decoder."""
global acars_active_device
with app_module.acars_lock: with app_module.acars_lock:
if not app_module.acars_process: if not app_module.acars_process:
return jsonify({ return jsonify({
@@ -334,6 +358,11 @@ def stop_acars() -> Response:
app_module.acars_process = None app_module.acars_process = None
# Release device from registry
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+26 -2
View File
@@ -33,7 +33,9 @@ from config import (
ADSB_DB_PASSWORD, ADSB_DB_PASSWORD,
ADSB_DB_PORT, ADSB_DB_PORT,
ADSB_DB_USER, ADSB_DB_USER,
ADSB_AUTO_START,
ADSB_HISTORY_ENABLED, ADSB_HISTORY_ENABLED,
SHARED_OBSERVER_LOCATION_ENABLED,
) )
from utils.logging import adsb_logger as logger from utils.logging import adsb_logger as logger
from utils.validation import ( from utils.validation import (
@@ -684,6 +686,16 @@ def start_adsb():
app_module.adsb_process = None app_module.adsb_process = None
logger.info("Killed stale ADS-B process") logger.info("Killed stale ADS-B process")
# Check if device is available before starting local dump1090
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'adsb')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Create device object and build command via abstraction layer # Create device object and build command via abstraction layer
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type) builder = SDRFactory.get_builder(sdr_type)
@@ -712,7 +724,8 @@ def start_adsb():
time.sleep(DUMP1090_START_WAIT) time.sleep(DUMP1090_START_WAIT)
if app_module.adsb_process.poll() is not None: if app_module.adsb_process.poll() is not None:
# Process exited - try to get error message # Process exited - release device and get error message
app_module.release_sdr_device(device_int)
stderr_output = '' stderr_output = ''
if app_module.adsb_process.stderr: if app_module.adsb_process.stderr:
try: try:
@@ -750,6 +763,8 @@ def start_adsb():
'session': session 'session': session
}) })
except Exception as e: except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@@ -777,6 +792,11 @@ def stop_adsb():
pass pass
app_module.adsb_process = None app_module.adsb_process = None
logger.info("ADS-B process stopped") logger.info("ADS-B process stopped")
# Release device from registry
if adsb_active_device is not None:
app_module.release_sdr_device(adsb_active_device)
adsb_using_service = False adsb_using_service = False
adsb_active_device = None adsb_active_device = None
@@ -812,7 +832,11 @@ def stream_adsb():
@adsb_bp.route('/dashboard') @adsb_bp.route('/dashboard')
def adsb_dashboard(): def adsb_dashboard():
"""Popout ADS-B dashboard.""" """Popout ADS-B dashboard."""
return render_template('adsb_dashboard.html') return render_template(
'adsb_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
adsb_auto_start=ADSB_AUTO_START,
)
@adsb_bp.route('/history') @adsb_bp.route('/history')
+24 -1
View File
@@ -15,6 +15,7 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response, render_template from flask import Blueprint, jsonify, request, Response, render_template
import app as app_module import app as app_module
from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.logging import get_logger from utils.logging import get_logger
from utils.validation import validate_device_index, validate_gain from utils.validation import validate_device_index, validate_gain
from utils.sse import format_sse from utils.sse import format_sse
@@ -369,6 +370,16 @@ def start_ais():
app_module.ais_process = None app_module.ais_process = None
logger.info("Killed existing AIS process") logger.info("Killed existing AIS process")
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Build command using SDR abstraction # Build command using SDR abstraction
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type) builder = SDRFactory.get_builder(sdr_type)
@@ -399,6 +410,8 @@ def start_ais():
time.sleep(2.0) time.sleep(2.0)
if app_module.ais_process.poll() is not None: if app_module.ais_process.poll() is not None:
# Release device on failure
app_module.release_sdr_device(device_int)
stderr_output = '' stderr_output = ''
if app_module.ais_process.stderr: if app_module.ais_process.stderr:
try: try:
@@ -424,6 +437,8 @@ def start_ais():
'port': tcp_port 'port': tcp_port
}) })
except Exception as e: except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
logger.error(f"Failed to start AIS-catcher: {e}") logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -447,6 +462,11 @@ def stop_ais():
pass pass
app_module.ais_process = None app_module.ais_process = None
logger.info("AIS process stopped") logger.info("AIS process stopped")
# Release device from registry
if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device)
ais_running = False ais_running = False
ais_active_device = None ais_active_device = None
@@ -480,4 +500,7 @@ def stream_ais():
@ais_bp.route('/dashboard') @ais_bp.route('/dashboard')
def ais_dashboard(): def ais_dashboard():
"""Popout AIS dashboard.""" """Popout AIS dashboard."""
return render_template('ais_dashboard.html') return render_template(
'ais_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
+66 -1
View File
@@ -91,6 +91,17 @@ def register_agent():
if not base_url: if not base_url:
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400 return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
# Validate URL format
from urllib.parse import urlparse
try:
parsed = urlparse(base_url)
if parsed.scheme not in ('http', 'https'):
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
if not parsed.netloc:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
# Check if agent already exists # Check if agent already exists
existing = get_agent_by_name(name) existing = get_agent_by_name(name)
if existing: if existing:
@@ -128,9 +139,12 @@ def register_agent():
update_agent(agent_id, update_last_seen=True) update_agent(agent_id, update_last_seen=True)
agent = get_agent(agent_id) agent = get_agent(agent_id)
message = 'Agent registered successfully'
if capabilities is None:
message += ' (could not connect - agent may be offline)'
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'message': 'Agent registered successfully', 'message': message,
'agent': agent 'agent': agent
}), 201 }), 201
@@ -466,6 +480,57 @@ def proxy_mode_data(agent_id: int, mode: str):
}), 502 }), 502
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
data = request.json or {}
try:
client = create_client_from_agent(agent)
result = client.post('/wifi/monitor', data)
# Refresh agent capabilities after monitor mode toggle so UI stays in sync
if result.get('status') == 'success':
try:
metadata = client.refresh_metadata()
if metadata.get('healthy'):
caps = metadata.get('capabilities') or {}
agent_interfaces = caps.get('interfaces', {})
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=caps.get('modes'),
interfaces=agent_interfaces,
update_last_seen=True
)
except Exception:
pass # Non-fatal if refresh fails
return jsonify({
'status': result.get('status', 'error'),
'agent_id': agent_id,
'agent_name': agent['name'],
'monitor_interface': result.get('monitor_interface'),
'message': result.get('message')
})
except AgentConnectionError as e:
return jsonify({
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
# ============================================================================= # =============================================================================
# Push Data Ingestion # Push Data Ingestion
# ============================================================================= # =============================================================================
+29 -16
View File
@@ -47,6 +47,9 @@ dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
# Module state (track if running independent of process state) # Module state (track if running independent of process state)
dsc_running = False dsc_running = False
# Track which device is being used
dsc_active_device: int | None = None
def _get_dsc_decoder_path() -> str | None: def _get_dsc_decoder_path() -> str | None:
"""Get path to DSC decoder.""" """Get path to DSC decoder."""
@@ -309,21 +312,18 @@ def start_decoding() -> Response:
'message': str(e) 'message': str(e)
}), 400 }), 400
# Check if device is in use by AIS # Check if device is available using centralized registry
try: global dsc_active_device
from routes import ais as ais_module device_int = int(device)
if hasattr(ais_module, 'ais_running') and ais_module.ais_running: error = app_module.claim_sdr_device(device_int, 'dsc')
# AIS is running - check if same device if error:
if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device): return jsonify({
return jsonify({ 'status': 'error',
'status': 'error', 'error_type': 'DEVICE_BUSY',
'error_type': 'DEVICE_BUSY', 'message': error
'message': f'SDR device {device} is in use by AIS tracking', }), 409
'suggestion': 'Use a different SDR device or stop AIS tracking first',
'in_use_by': 'ais' dsc_active_device = device_int
}), 409
except ImportError:
pass
# Clear queue # Clear queue
while not app_module.dsc_queue.empty(): while not app_module.dsc_queue.empty():
@@ -408,11 +408,19 @@ def start_decoding() -> Response:
}) })
except FileNotFoundError as e: except FileNotFoundError as e:
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': f'Tool not found: {e.filename}' 'message': f'Tool not found: {e.filename}'
}), 400 }), 400
except Exception as e: except Exception as e:
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
logger.error(f"Failed to start DSC decoder: {e}") logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -423,7 +431,7 @@ def start_decoding() -> Response:
@dsc_bp.route('/stop', methods=['POST']) @dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response: def stop_decoding() -> Response:
"""Stop DSC decoder.""" """Stop DSC decoder."""
global dsc_running global dsc_running, dsc_active_device
with app_module.dsc_lock: with app_module.dsc_lock:
if not app_module.dsc_process: if not app_module.dsc_process:
@@ -460,6 +468,11 @@ def stop_decoding() -> Response:
app_module.dsc_process = None app_module.dsc_process = None
app_module.dsc_rtl_process = None app_module.dsc_rtl_process = None
# Release device from registry
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+49 -9
View File
@@ -3,8 +3,9 @@
Provides endpoints for connecting to Meshtastic devices, configuring Provides endpoints for connecting to Meshtastic devices, configuring
channels with encryption keys, and streaming received messages. channels with encryption keys, and streaming received messages.
Requires a physical Meshtastic device (Heltec, T-Beam, RAK, etc.) Supports multiple connection types:
connected via USB/Serial. - USB/Serial: Physical device connected via USB
- TCP: WiFi-enabled devices accessible via IP address
""" """
from __future__ import annotations from __future__ import annotations
@@ -95,7 +96,7 @@ def get_status():
Get Meshtastic connection status. Get Meshtastic connection status.
Returns: Returns:
JSON with connection status, device info, and node information. JSON with connection status, device info, connection type, and node information.
""" """
if not is_meshtastic_available(): if not is_meshtastic_available():
return jsonify({ return jsonify({
@@ -111,6 +112,7 @@ def get_status():
'available': True, 'available': True,
'running': False, 'running': False,
'device': None, 'device': None,
'connection_type': None,
'node_info': None, 'node_info': None,
}) })
@@ -120,6 +122,7 @@ def get_status():
'available': True, 'available': True,
'running': client.is_running, 'running': client.is_running,
'device': client.device_path, 'device': client.device_path,
'connection_type': client.connection_type,
'error': client.error, 'error': client.error,
'node_info': node_info.to_dict() if node_info else None, 'node_info': node_info.to_dict() if node_info else None,
}) })
@@ -131,13 +134,20 @@ def start_mesh():
Start Meshtastic listener. Start Meshtastic listener.
Connects to a Meshtastic device and begins receiving messages. Connects to a Meshtastic device and begins receiving messages.
The device must be connected via USB/Serial. Supports both USB/Serial and TCP connections.
JSON body (optional): JSON body (optional):
{ {
"device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided. "connection_type": "serial", // 'serial' (default) or 'tcp'
"device": "/dev/ttyUSB0", // Serial port path. Auto-discovers if not provided.
"hostname": "192.168.1.100" // IP address or hostname for TCP connections
} }
Examples:
Serial (auto-discover): {}
Serial (specific port): {"device": "/dev/ttyUSB0"}
TCP: {"connection_type": "tcp", "hostname": "192.168.1.100"}
Returns: Returns:
JSON with connection status. JSON with connection status.
""" """
@@ -151,7 +161,8 @@ def start_mesh():
if client and client.is_running: if client and client.is_running:
return jsonify({ return jsonify({
'status': 'already_running', 'status': 'already_running',
'device': client.device_path 'device': client.device_path,
'connection_type': client.connection_type
}) })
# Clear queue and history # Clear queue and history
@@ -162,18 +173,46 @@ def start_mesh():
break break
_recent_messages.clear() _recent_messages.clear()
# Get optional device path # Parse connection parameters
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
connection_type = data.get('connection_type', 'serial').lower().strip()
device = data.get('device') device = data.get('device')
hostname = data.get('hostname')
# Validate device path if provided # Validate connection type
if connection_type not in ('serial', 'tcp'):
return jsonify({
'status': 'error',
'message': f"Invalid connection_type: {connection_type}. Must be 'serial' or 'tcp'"
}), 400
# Validate TCP parameters
if connection_type == 'tcp':
if not hostname:
return jsonify({
'status': 'error',
'message': 'hostname is required for TCP connections'
}), 400
hostname = str(hostname).strip()
if not hostname:
return jsonify({
'status': 'error',
'message': 'hostname cannot be empty'
}), 400
# Validate serial device path if provided
if device: if device:
device = str(device).strip() device = str(device).strip()
if not device: if not device:
device = None device = None
# Start client # Start client
success = start_meshtastic(device=device, callback=_message_callback) success = start_meshtastic(
device=device,
callback=_message_callback,
connection_type=connection_type,
hostname=hostname
)
if success: if success:
client = get_meshtastic_client() client = get_meshtastic_client()
@@ -181,6 +220,7 @@ def start_mesh():
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'device': client.device_path if client else None, 'device': client.device_path if client else None,
'connection_type': client.connection_type if client else None,
'node_info': node_info.to_dict() if node_info else None, 'node_info': node_info.to_dict() if node_info else None,
}) })
else: else:
+40 -4
View File
@@ -29,6 +29,9 @@ from utils.dependencies import get_tool_path
pager_bp = Blueprint('pager', __name__) pager_bp = Blueprint('pager', __name__)
# Track which device is being used
pager_active_device: int | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None: def parse_multimon_output(line: str) -> dict[str, str] | None:
"""Parse multimon-ng output line.""" """Parse multimon-ng output line."""
@@ -155,6 +158,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
@pager_bp.route('/start', methods=['POST']) @pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response: def start_decoding() -> Response:
global pager_active_device
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
return jsonify({'status': 'error', 'message': 'Already running'}), 409 return jsonify({'status': 'error', 'message': 'Already running'}), 409
@@ -178,10 +183,29 @@ def start_decoding() -> Response:
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400 return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
pager_active_device = device_int
# Validate protocols # Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'] valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols) protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list): if not isinstance(protocols, list):
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400 return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
protocols = [p for p in protocols if p in valid_protocols] protocols = [p for p in protocols if p in valid_protocols]
if not protocols: if not protocols:
@@ -213,10 +237,6 @@ def start_decoding() -> Response:
except ValueError: except ValueError:
sdr_type = SDRType.RTL_SDR sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if rtl_tcp_host: if rtl_tcp_host:
# Validate and create network device # Validate and create network device
try: try:
@@ -302,13 +322,23 @@ def start_decoding() -> Response:
return jsonify({'status': 'started', 'command': full_cmd}) return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError as e: except FileNotFoundError as e:
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e: except Exception as e:
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@pager_bp.route('/stop', methods=['POST']) @pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response: def stop_decoding() -> Response:
global pager_active_device
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
# Kill rtl_fm process first # Kill rtl_fm process first
@@ -337,6 +367,12 @@ def stop_decoding() -> Response:
app_module.current_process.kill() app_module.current_process.kill()
app_module.current_process = None app_module.current_process = None
# Release device from registry
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'}) return jsonify({'status': 'not_running'})
+30 -4
View File
@@ -26,6 +26,9 @@ rtlamr_bp = Blueprint('rtlamr', __name__)
rtl_tcp_process = None rtl_tcp_process = None
rtl_tcp_lock = threading.Lock() rtl_tcp_lock = threading.Lock()
# Track which device is being used
rtlamr_active_device: int | None = None
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None: def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtlamr JSON output to queue.""" """Stream rtlamr JSON output to queue."""
@@ -66,7 +69,7 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
@rtlamr_bp.route('/start_rtlamr', methods=['POST']) @rtlamr_bp.route('/start_rtlamr', methods=['POST'])
def start_rtlamr() -> Response: def start_rtlamr() -> Response:
global rtl_tcp_process global rtl_tcp_process, rtlamr_active_device
with app_module.rtlamr_lock: with app_module.rtlamr_lock:
if app_module.rtlamr_process: if app_module.rtlamr_process:
@@ -83,6 +86,18 @@ def start_rtlamr() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'rtlamr')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
rtlamr_active_device = device_int
# Clear queue # Clear queue
while not app_module.rtlamr_queue.empty(): while not app_module.rtlamr_queue.empty():
try: try:
@@ -182,26 +197,32 @@ def start_rtlamr() -> Response:
return jsonify({'status': 'started', 'command': full_cmd}) return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError: except FileNotFoundError:
# If rtlamr fails, clean up rtl_tcp # If rtlamr fails, clean up rtl_tcp and release device
with rtl_tcp_lock: with rtl_tcp_lock:
if rtl_tcp_process: if rtl_tcp_process:
rtl_tcp_process.terminate() rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2) rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'}) return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
except Exception as e: except Exception as e:
# If rtlamr fails, clean up rtl_tcp # If rtlamr fails, clean up rtl_tcp and release device
with rtl_tcp_lock: with rtl_tcp_lock:
if rtl_tcp_process: if rtl_tcp_process:
rtl_tcp_process.terminate() rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2) rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@rtlamr_bp.route('/stop_rtlamr', methods=['POST']) @rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response: def stop_rtlamr() -> Response:
global rtl_tcp_process global rtl_tcp_process, rtlamr_active_device
with app_module.rtlamr_lock: with app_module.rtlamr_lock:
if app_module.rtlamr_process: if app_module.rtlamr_process:
@@ -223,6 +244,11 @@ def stop_rtlamr() -> Response:
rtl_tcp_process = None rtl_tcp_process = None
logger.info("rtl_tcp stopped") logger.info("rtl_tcp stopped")
# Release device from registry
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+6 -1
View File
@@ -13,6 +13,8 @@ import requests
from flask import Blueprint, jsonify, request, render_template, Response from flask import Blueprint, jsonify, request, render_template, Response
from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
from utils.logging import satellite_logger as logger from utils.logging import satellite_logger as logger
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
@@ -120,7 +122,10 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
@satellite_bp.route('/dashboard') @satellite_bp.route('/dashboard')
def satellite_dashboard(): def satellite_dashboard():
"""Popout satellite tracking dashboard.""" """Popout satellite tracking dashboard."""
return render_template('satellite_dashboard.html') return render_template(
'satellite_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
@satellite_bp.route('/predict', methods=['POST']) @satellite_bp.route('/predict', methods=['POST'])
+37 -4
View File
@@ -24,6 +24,9 @@ from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__) sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtl_433 JSON output to queue.""" """Stream rtl_433 JSON output to queue."""
@@ -64,6 +67,8 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
@sensor_bp.route('/start_sensor', methods=['POST']) @sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response: def start_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409 return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
@@ -79,6 +84,22 @@ def start_sensor() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int
# Clear queue # Clear queue
while not app_module.sensor_queue.empty(): while not app_module.sensor_queue.empty():
try: try:
@@ -93,10 +114,6 @@ def start_sensor() -> Response:
except ValueError: except ValueError:
sdr_type = SDRType.RTL_SDR sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if rtl_tcp_host: if rtl_tcp_host:
# Validate and create network device # Validate and create network device
try: try:
@@ -155,13 +172,23 @@ def start_sensor() -> Response:
return jsonify({'status': 'started', 'command': full_cmd}) return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError: except FileNotFoundError:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e: except Exception as e:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST']) @sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response: def stop_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
app_module.sensor_process.terminate() app_module.sensor_process.terminate()
@@ -170,6 +197,12 @@ def stop_sensor() -> Response:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
app_module.sensor_process.kill() app_module.sensor_process.kill()
app_module.sensor_process = None app_module.sensor_process = None
# Release device from registry
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'}) return jsonify({'status': 'not_running'})
+95 -8
View File
@@ -20,6 +20,7 @@ from utils.sstv import (
is_sstv_available, is_sstv_available,
ISS_SSTV_FREQ, ISS_SSTV_FREQ,
DecodeProgress, DecodeProgress,
DopplerInfo,
) )
logger = get_logger('intercept.sstv') logger = get_logger('intercept.sstv')
@@ -53,13 +54,21 @@ def get_status():
available = is_sstv_available() available = is_sstv_available()
decoder = get_sstv_decoder() decoder = get_sstv_decoder()
return jsonify({ result = {
'available': available, 'available': available,
'decoder': decoder.decoder_available, 'decoder': decoder.decoder_available,
'running': decoder.is_running, 'running': decoder.is_running,
'iss_frequency': ISS_SSTV_FREQ, 'iss_frequency': ISS_SSTV_FREQ,
'image_count': len(decoder.get_images()), 'image_count': len(decoder.get_images()),
}) 'doppler_enabled': decoder.doppler_enabled,
}
# Include Doppler info if available
doppler_info = decoder.last_doppler_info
if doppler_info:
result['doppler'] = doppler_info.to_dict()
return jsonify(result)
@sstv_bp.route('/start', methods=['POST']) @sstv_bp.route('/start', methods=['POST'])
@@ -70,9 +79,15 @@ def start_decoder():
JSON body (optional): JSON body (optional):
{ {
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800) "frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
"device": 0 // RTL-SDR device index "device": 0, // RTL-SDR device index
"latitude": 40.7128, // Observer latitude for Doppler correction
"longitude": -74.0060 // Observer longitude for Doppler correction
} }
If latitude and longitude are provided, real-time Doppler shift compensation
will be enabled, which improves reception by tracking the ISS frequency shift
as it passes overhead (up to ±3.5 kHz at 145.800 MHz).
Returns: Returns:
JSON with start status. JSON with start status.
""" """
@@ -87,7 +102,8 @@ def start_decoder():
if decoder.is_running: if decoder.is_running:
return jsonify({ return jsonify({
'status': 'already_running', 'status': 'already_running',
'frequency': ISS_SSTV_FREQ 'frequency': ISS_SSTV_FREQ,
'doppler_enabled': decoder.doppler_enabled
}) })
# Clear queue # Clear queue
@@ -101,6 +117,8 @@ def start_decoder():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
frequency = data.get('frequency', ISS_SSTV_FREQ) frequency = data.get('frequency', ISS_SSTV_FREQ)
device_index = data.get('device', 0) device_index = data.get('device', 0)
latitude = data.get('latitude')
longitude = data.get('longitude')
# Validate frequency # Validate frequency
try: try:
@@ -116,16 +134,52 @@ def start_decoder():
'message': 'Invalid frequency' 'message': 'Invalid frequency'
}), 400 }), 400
# Validate location if provided
if latitude is not None and longitude is not None:
try:
latitude = float(latitude)
longitude = float(longitude)
if not (-90 <= latitude <= 90):
return jsonify({
'status': 'error',
'message': 'Latitude must be between -90 and 90'
}), 400
if not (-180 <= longitude <= 180):
return jsonify({
'status': 'error',
'message': 'Longitude must be between -180 and 180'
}), 400
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid latitude or longitude'
}), 400
else:
latitude = None
longitude = None
# Set callback and start # Set callback and start
decoder.set_callback(_progress_callback) decoder.set_callback(_progress_callback)
success = decoder.start(frequency=frequency, device_index=device_index) success = decoder.start(
frequency=frequency,
device_index=device_index,
latitude=latitude,
longitude=longitude
)
if success: if success:
return jsonify({ result = {
'status': 'started', 'status': 'started',
'frequency': frequency, 'frequency': frequency,
'device': device_index 'device': device_index,
}) 'doppler_enabled': decoder.doppler_enabled
}
# Include initial Doppler info if available
if decoder.doppler_enabled and decoder.last_doppler_info:
result['doppler'] = decoder.last_doppler_info.to_dict()
return jsonify(result)
else: else:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -146,6 +200,39 @@ def stop_decoder():
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@sstv_bp.route('/doppler')
def get_doppler():
"""
Get current Doppler shift information.
Returns real-time Doppler shift data if tracking is enabled.
Returns:
JSON with Doppler shift information.
"""
decoder = get_sstv_decoder()
if not decoder.doppler_enabled:
return jsonify({
'status': 'disabled',
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
})
doppler_info = decoder.last_doppler_info
if not doppler_info:
return jsonify({
'status': 'unavailable',
'message': 'Doppler data not yet available'
})
return jsonify({
'status': 'ok',
'doppler': doppler_info.to_dict(),
'nominal_frequency_mhz': ISS_SSTV_FREQ,
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
})
@sstv_bp.route('/images') @sstv_bp.route('/images')
def list_images(): def list_images():
""" """
+140
View File
@@ -1413,3 +1413,143 @@ def v2_clear_data():
except Exception as e: except Exception as e:
logger.exception("Error clearing data") logger.exception("Error clearing data")
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# =============================================================================
# V2 Deauth Detection Endpoints
# =============================================================================
@wifi_bp.route('/v2/deauth/status')
def v2_deauth_status():
"""
Get deauth detection status and recent alerts.
Returns:
- is_running: Whether deauth detector is active
- interface: Monitor interface being used
- stats: Detection statistics
- recent_alerts: Recent deauth alerts
"""
try:
scanner = get_wifi_scanner()
detector = scanner.deauth_detector
if detector:
stats = detector.stats
alerts = detector.get_alerts(limit=50)
else:
stats = {
'is_running': False,
'interface': None,
'packets_captured': 0,
'alerts_generated': 0,
}
alerts = []
return jsonify({
'is_running': stats.get('is_running', False),
'interface': stats.get('interface'),
'started_at': stats.get('started_at'),
'stats': {
'packets_captured': stats.get('packets_captured', 0),
'alerts_generated': stats.get('alerts_generated', 0),
'active_trackers': stats.get('active_trackers', 0),
},
'recent_alerts': alerts,
})
except Exception as e:
logger.exception("Error getting deauth status")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/deauth/stream')
def v2_deauth_stream():
"""
SSE stream for real-time deauth alerts.
Events:
- deauth_alert: A deauth attack was detected
- deauth_detector_started: Detector started
- deauth_detector_stopped: Detector stopped
- deauth_error: An error occurred
- keepalive: Periodic keepalive
"""
def generate():
last_keepalive = time.time()
keepalive_interval = SSE_KEEPALIVE_INTERVAL
while True:
try:
# Try to get from the dedicated deauth queue
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@wifi_bp.route('/v2/deauth/alerts')
def v2_deauth_alerts():
"""
Get historical deauth alerts.
Query params:
- limit: Maximum number of alerts to return (default 100)
"""
try:
limit = request.args.get('limit', 100, type=int)
limit = max(1, min(limit, 1000)) # Clamp between 1 and 1000
scanner = get_wifi_scanner()
alerts = scanner.get_deauth_alerts(limit=limit)
# Also include alerts from DataStore that might have been persisted
try:
stored_alerts = list(app_module.deauth_alerts.values())
# Merge and deduplicate by ID
alert_ids = {a.get('id') for a in alerts}
for alert in stored_alerts:
if alert.get('id') not in alert_ids:
alerts.append(alert)
# Sort by timestamp descending
alerts.sort(key=lambda a: a.get('timestamp', 0), reverse=True)
alerts = alerts[:limit]
except Exception:
pass
return jsonify({
'alerts': alerts,
'count': len(alerts),
})
except Exception as e:
logger.exception("Error getting deauth alerts")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/deauth/clear', methods=['POST'])
def v2_deauth_clear():
"""Clear deauth alert history."""
try:
scanner = get_wifi_scanner()
scanner.clear_deauth_alerts()
# Clear the queue
while not app_module.deauth_detector_queue.empty():
try:
app_module.deauth_detector_queue.get_nowait()
except queue.Empty:
break
return jsonify({'status': 'cleared'})
except Exception as e:
logger.exception("Error clearing deauth alerts")
return jsonify({'error': str(e)}), 500
+92 -16
View File
@@ -204,6 +204,7 @@ check_tools() {
check_required "dump1090" "ADS-B decoder" dump1090 check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "slowrx" "SSTV decoder (ISS images)" slowrx
echo echo
info "GPS:" info "GPS:"
@@ -385,6 +386,49 @@ install_rtlamr_from_source() {
fi fi
} }
install_slowrx_from_source_macos() {
info "slowrx not available via Homebrew. Building from source..."
# Ensure build dependencies are installed
brew_install cmake
brew_install fftw
brew_install libsndfile
brew_install gtk+3
brew_install pkg-config
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning slowrx..."
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|| { warn "Failed to clone slowrx"; exit 1; }
cd "$tmp_dir/slowrx"
info "Compiling slowrx..."
mkdir -p build && cd build
local cmake_log make_log
cmake_log=$(cmake .. 2>&1) || {
warn "cmake failed for slowrx:"
echo "$cmake_log" | tail -20
exit 1
}
make_log=$(make 2>&1) || {
warn "make failed for slowrx:"
echo "$make_log" | tail -20
exit 1
}
# Install to /usr/local/bin
if [[ -w /usr/local/bin ]]; then
install -m 0755 slowrx /usr/local/bin/slowrx
else
sudo install -m 0755 slowrx /usr/local/bin/slowrx
fi
ok "slowrx installed successfully from source"
)
}
install_multimon_ng_from_source_macos() { install_multimon_ng_from_source_macos() {
info "multimon-ng not available via Homebrew. Building from source..." info "multimon-ng not available via Homebrew. Building from source..."
@@ -417,7 +461,7 @@ install_multimon_ng_from_source_macos() {
} }
install_macos_packages() { install_macos_packages() {
TOTAL_STEPS=15 TOTAL_STEPS=16
CURRENT_STEP=0 CURRENT_STEP=0
progress "Checking Homebrew" progress "Checking Homebrew"
@@ -437,6 +481,13 @@ install_macos_packages() {
progress "Installing direwolf (APRS decoder)" progress "Installing direwolf (APRS decoder)"
(brew_install direwolf) || warn "direwolf not available via Homebrew" (brew_install direwolf) || warn "direwolf not available via Homebrew"
progress "Installing slowrx (SSTV decoder)"
if ! cmd_exists slowrx; then
install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available"
else
ok "slowrx already installed"
fi
progress "Installing ffmpeg" progress "Installing ffmpeg"
brew_install ffmpeg brew_install ffmpeg
@@ -632,6 +683,37 @@ install_aiscatcher_from_source_debian() {
) )
} }
install_slowrx_from_source_debian() {
info "slowrx not available via APT. Building from source..."
# slowrx uses a simple Makefile, not CMake
apt_install build-essential git pkg-config \
libfftw3-dev libsndfile1-dev libgtk-3-dev libasound2-dev libpulse-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning slowrx..."
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|| { warn "Failed to clone slowrx"; exit 1; }
cd "$tmp_dir/slowrx"
info "Compiling slowrx..."
local make_log
make_log=$(make 2>&1) || {
warn "make failed for slowrx:"
echo "$make_log" | tail -20
warn "ISS SSTV decoding will not be available."
exit 1
}
$SUDO install -m 0755 slowrx /usr/local/bin/slowrx
ok "slowrx installed successfully."
)
}
install_ubertooth_from_source_debian() { install_ubertooth_from_source_debian() {
info "Building Ubertooth from source..." info "Building Ubertooth from source..."
@@ -767,7 +849,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a export NEEDRESTART_MODE=a
fi fi
TOTAL_STEPS=20 TOTAL_STEPS=21
CURRENT_STEP=0 CURRENT_STEP=0
progress "Updating APT package lists" progress "Updating APT package lists"
@@ -811,19 +893,9 @@ install_debian_packages() {
progress "RTL-SDR Blog drivers" progress "RTL-SDR Blog drivers"
if cmd_exists rtl_test; then if cmd_exists rtl_test; then
info "RTL-SDR tools already installed." ok "RTL-SDR drivers already installed"
if $IS_DRAGONOS; then
info "Skipping RTL-SDR Blog driver installation (DragonOS has working drivers)."
else
echo "RTL-SDR Blog drivers provide improved support for V4 dongles."
echo "Installing these will REPLACE your current RTL-SDR drivers."
if ask_yes_no "Install RTL-SDR Blog drivers?"; then
install_rtlsdr_blog_drivers_debian
else
ok "Keeping existing RTL-SDR drivers."
fi
fi
else else
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
install_rtlsdr_blog_drivers_debian install_rtlsdr_blog_drivers_debian
fi fi
@@ -833,6 +905,9 @@ install_debian_packages() {
progress "Installing direwolf (APRS decoder)" progress "Installing direwolf (APRS decoder)"
apt_install direwolf || true apt_install direwolf || true
progress "Installing slowrx (SSTV decoder)"
apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian
progress "Installing ffmpeg" progress "Installing ffmpeg"
apt_install ffmpeg apt_install ffmpeg
@@ -922,11 +997,12 @@ install_debian_packages() {
setup_udev_rules_debian setup_udev_rules_debian
progress "Kernel driver configuration" progress "Kernel driver configuration"
echo
if $IS_DRAGONOS; then if $IS_DRAGONOS; then
info "DragonOS already has RTL-SDR drivers configured correctly." info "DragonOS already has RTL-SDR drivers configured correctly."
info "Skipping kernel driver blacklist (not needed)." elif [[ -f /etc/modprobe.d/blacklist-rtlsdr.conf ]]; then
ok "DVB kernel drivers already blacklisted"
else else
echo
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access." echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
echo "Blacklisting them allows rtl_sdr tools to access the device." echo "Blacklisting them allows rtl_sdr tools to access the device."
if ask_yes_no "Blacklist conflicting kernel drivers?"; then if ask_yes_no "Blacklist conflicting kernel drivers?"; then
+18
View File
@@ -814,6 +814,24 @@ body {
color: var(--accent-green); color: var(--accent-green);
} }
/* Bias-T toggle styling */
.bias-t-label {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: linear-gradient(90deg, rgba(255, 100, 0, 0.15), rgba(255, 100, 0, 0.05));
border: 1px solid var(--accent-orange, #ff6400);
border-radius: 4px;
color: var(--accent-orange, #ff6400);
font-weight: 500;
font-size: 10px;
}
.bias-t-label input[type="checkbox"] {
accent-color: var(--accent-orange, #ff6400);
}
.control-group.airband-group { .control-group.airband-group {
background: rgba(245, 158, 11, 0.05); background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.2); border-color: rgba(245, 158, 11, 0.2);
+12
View File
@@ -978,6 +978,18 @@ header h1 {
color: var(--accent-cyan); color: var(--accent-cyan);
} }
/* Donate button - warm amber accent */
.nav-tool-btn--donate {
text-decoration: none;
color: var(--accent-amber);
}
.nav-tool-btn--donate:hover {
color: var(--accent-orange);
border-color: var(--accent-amber);
background: rgba(212, 168, 83, 0.1);
}
/* Theme toggle icon states in nav bar */ /* Theme toggle icon states in nav bar */
.nav-tool-btn .icon-sun, .nav-tool-btn .icon-sun,
.nav-tool-btn .icon-moon { .nav-tool-btn .icon-moon {
+21
View File
@@ -259,6 +259,27 @@
max-width: 120px; max-width: 120px;
} }
.mesh-strip-input {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
padding: 4px 8px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
max-width: 140px;
}
.mesh-strip-input::placeholder {
color: var(--text-secondary);
opacity: 0.7;
}
.mesh-strip-input:focus {
outline: none;
border-color: var(--accent-cyan);
}
.mesh-strip-btn { .mesh-strip-btn {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 10px; font-size: 10px;
+28
View File
@@ -351,6 +351,34 @@
color: var(--accent-cyan, #00d4ff); color: var(--accent-cyan, #00d4ff);
} }
/* Donate Button */
.donate-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
border: none;
border-radius: 6px;
color: #000;
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
}
.donate-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
filter: brightness(1.1);
}
.donate-btn:active {
transform: translateY(0);
}
/* Tile Provider Custom URL */ /* Tile Provider Custom URL */
.custom-url-row { .custom-url-row {
margin-top: 8px; margin-top: 8px;
+3
View File
@@ -32,6 +32,9 @@ let muted = localStorage.getItem('audioMuted') === 'true';
// Observer location (load from localStorage or default to London) // Observer location (load from localStorage or default to London)
let observerLocation = (function() { let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
}
const saved = localStorage.getItem('observerLocation'); const saved = localStorage.getItem('observerLocation');
if (saved) { if (saved) {
try { try {
+103
View File
@@ -0,0 +1,103 @@
// Shared observer location helper for map-based modules.
// Default: shared location enabled unless explicitly disabled via config.
window.ObserverLocation = (function() {
const DEFAULT_LOCATION = { lat: 51.5074, lon: -0.1278 };
const SHARED_KEY = 'observerLocation';
const AIS_KEY = 'ais_observerLocation';
const LEGACY_LAT_KEY = 'observerLat';
const LEGACY_LON_KEY = 'observerLon';
function isSharedEnabled() {
return window.INTERCEPT_SHARED_OBSERVER_LOCATION !== false;
}
function normalize(lat, lon) {
const latNum = parseFloat(lat);
const lonNum = parseFloat(lon);
if (Number.isNaN(latNum) || Number.isNaN(lonNum)) return null;
if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) return null;
return { lat: latNum, lon: lonNum };
}
function parseLocation(raw) {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && parsed.lat !== undefined && parsed.lon !== undefined) {
return normalize(parsed.lat, parsed.lon);
}
} catch (e) {}
return null;
}
function readKey(key) {
return parseLocation(localStorage.getItem(key));
}
function readLegacyLatLon() {
const lat = localStorage.getItem(LEGACY_LAT_KEY);
const lon = localStorage.getItem(LEGACY_LON_KEY);
if (!lat || !lon) return null;
return normalize(lat, lon);
}
function getShared() {
const current = readKey(SHARED_KEY);
if (current) return current;
const legacy = readKey(AIS_KEY) || readLegacyLatLon();
if (legacy) {
setShared(legacy);
return legacy;
}
return { ...DEFAULT_LOCATION };
}
function setShared(location, options = {}) {
if (!location) return;
localStorage.setItem(SHARED_KEY, JSON.stringify(location));
if (options.updateLegacy !== false) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
}
}
function getForModule(moduleKey, options = {}) {
if (isSharedEnabled()) {
return getShared();
}
if (moduleKey) {
const moduleLocation = readKey(moduleKey);
if (moduleLocation) return moduleLocation;
}
if (options.fallbackToLatLon) {
const legacy = readLegacyLatLon();
if (legacy) return legacy;
}
return { ...DEFAULT_LOCATION };
}
function setForModule(moduleKey, location, options = {}) {
if (!location) return;
if (isSharedEnabled()) {
setShared(location, options);
return;
}
if (moduleKey) {
localStorage.setItem(moduleKey, JSON.stringify(location));
} else if (options.fallbackToLatLon) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
}
}
return {
isSharedEnabled,
getShared,
setShared,
getForModule,
setForModule,
normalize,
DEFAULT_LOCATION: { ...DEFAULT_LOCATION }
};
})();
+34 -4
View File
@@ -548,8 +548,13 @@ document.addEventListener('DOMContentLoaded', () => {
* Load and display current observer location * Load and display current observer location
*/ */
function loadObserverLocation() { function loadObserverLocation() {
const lat = localStorage.getItem('observerLat'); let lat = localStorage.getItem('observerLat');
const lon = localStorage.getItem('observerLon'); let lon = localStorage.getItem('observerLon');
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
lat = shared.lat.toString();
lon = shared.lon.toString();
}
const latInput = document.getElementById('observerLatInput'); const latInput = document.getElementById('observerLatInput');
const lonInput = document.getElementById('observerLonInput'); const lonInput = document.getElementById('observerLonInput');
@@ -565,6 +570,17 @@ function loadObserverLocation() {
if (currentLonDisplay) { if (currentLonDisplay) {
currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set'; currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
} }
// Sync dashboard-specific location keys for backward compatibility
if (lat && lon) {
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
if (!localStorage.getItem('observerLocation')) {
localStorage.setItem('observerLocation', locationObj);
}
if (!localStorage.getItem('ais_observerLocation')) {
localStorage.setItem('ais_observerLocation', locationObj);
}
}
} }
/** /**
@@ -647,8 +663,17 @@ function saveObserverLocation() {
return; return;
} }
localStorage.setItem('observerLat', lat.toString()); if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
localStorage.setItem('observerLon', lon.toString()); ObserverLocation.setShared({ lat, lon });
} else {
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
}
// Also update dashboard-specific location keys for ADS-B and AIS
const locationObj = JSON.stringify({ lat: lat, lon: lon });
localStorage.setItem('observerLocation', locationObj); // ADS-B dashboard
localStorage.setItem('ais_observerLocation', locationObj); // AIS dashboard
// Update display // Update display
const currentLatDisplay = document.getElementById('currentLatDisplay'); const currentLatDisplay = document.getElementById('currentLatDisplay');
@@ -660,6 +685,11 @@ function saveObserverLocation() {
showNotification('Location', 'Observer location saved'); showNotification('Location', 'Observer location saved');
} }
if (window.observerLocation) {
window.observerLocation.lat = lat;
window.observerLocation.lon = lon;
}
// Refresh SSTV ISS schedule if available // Refresh SSTV ISS schedule if available
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') { if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
SSTV.loadIssSchedule(); SSTV.loadIssSchedule();
+74 -17
View File
@@ -143,7 +143,7 @@ const Meshtastic = (function() {
if (data.running) { if (data.running) {
isConnected = true; isConnected = true;
updateConnectionUI(true, data.device); updateConnectionUI(true, data.device, data.connection_type);
if (data.node_info) { if (data.node_info) {
updateNodeInfo(data.node_info); updateNodeInfo(data.node_info);
localNodeId = data.node_info.num; localNodeId = data.node_info.num;
@@ -158,21 +158,64 @@ const Meshtastic = (function() {
} }
} }
/**
* Handle connection type change (serial vs TCP)
*/
function onConnectionTypeChange() {
const connTypeSelect = document.getElementById('meshStripConnType');
const deviceSelect = document.getElementById('meshStripDevice');
const hostnameInput = document.getElementById('meshStripHostname');
if (!connTypeSelect) return;
const connType = connTypeSelect.value;
if (connType === 'tcp') {
// Show hostname input, hide device select
if (deviceSelect) deviceSelect.style.display = 'none';
if (hostnameInput) hostnameInput.style.display = 'block';
} else {
// Show device select, hide hostname input
if (deviceSelect) deviceSelect.style.display = 'block';
if (hostnameInput) hostnameInput.style.display = 'none';
}
}
/** /**
* Start Meshtastic connection * Start Meshtastic connection
*/ */
async function start() { async function start() {
// Try strip device select first, then sidebar // Get connection type
const stripDeviceSelect = document.getElementById('meshStripDevice'); const connTypeSelect = document.getElementById('meshStripConnType');
const sidebarDeviceSelect = document.getElementById('meshDeviceSelect'); const connectionType = connTypeSelect?.value || 'serial';
let device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null;
// Check if auto-detect is selected but multiple ports exist // Get connection parameters based on type
if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) { let device = null;
// Multiple ports available - prompt user to select one let hostname = null;
showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning');
updateStatusIndicator('disconnected', 'Select a device'); if (connectionType === 'tcp') {
return; // TCP connection - get hostname
const hostnameInput = document.getElementById('meshStripHostname');
hostname = hostnameInput?.value?.trim() || null;
if (!hostname) {
showStatusMessage('Please enter a hostname or IP address for TCP connection', 'error');
updateStatusIndicator('disconnected', 'Enter hostname');
return;
}
} else {
// Serial connection - get device
const stripDeviceSelect = document.getElementById('meshStripDevice');
const sidebarDeviceSelect = document.getElementById('meshDeviceSelect');
device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null;
// Check if auto-detect is selected but multiple ports exist
if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) {
// Multiple ports available - prompt user to select one
showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning');
updateStatusIndicator('disconnected', 'Select a device');
return;
}
} }
updateStatusIndicator('connecting', 'Connecting...'); updateStatusIndicator('connecting', 'Connecting...');
@@ -184,17 +227,27 @@ const Meshtastic = (function() {
if (stripStatus) stripStatus.textContent = 'Connecting...'; if (stripStatus) stripStatus.textContent = 'Connecting...';
try { try {
const requestBody = {
connection_type: connectionType
};
if (connectionType === 'tcp') {
requestBody.hostname = hostname;
} else if (device) {
requestBody.device = device;
}
const response = await fetch('/meshtastic/start', { const response = await fetch('/meshtastic/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device: device || undefined }) body: JSON.stringify(requestBody)
}); });
const data = await response.json(); const data = await response.json();
if (data.status === 'started' || data.status === 'already_running') { if (data.status === 'started' || data.status === 'already_running') {
isConnected = true; isConnected = true;
updateConnectionUI(true, data.device); updateConnectionUI(true, data.device, data.connection_type);
if (data.node_info) { if (data.node_info) {
updateNodeInfo(data.node_info); updateNodeInfo(data.node_info);
localNodeId = data.node_info.num; localNodeId = data.node_info.num;
@@ -202,7 +255,8 @@ const Meshtastic = (function() {
loadChannels(); loadChannels();
loadNodes(); loadNodes();
startStream(); startStream();
showNotification('Meshtastic', 'Connected to device'); const connLabel = data.connection_type === 'tcp' ? 'TCP' : 'Serial';
showNotification('Meshtastic', `Connected via ${connLabel}`);
} else { } else {
updateStatusIndicator('disconnected', data.message || 'Connection failed'); updateStatusIndicator('disconnected', data.message || 'Connection failed');
showStatusMessage(data.message || 'Failed to connect', 'error'); showStatusMessage(data.message || 'Failed to connect', 'error');
@@ -232,7 +286,7 @@ const Meshtastic = (function() {
/** /**
* Update connection UI state * Update connection UI state
*/ */
function updateConnectionUI(connected, device) { function updateConnectionUI(connected, device, connectionType) {
const connectBtn = document.getElementById('meshConnectBtn'); const connectBtn = document.getElementById('meshConnectBtn');
const disconnectBtn = document.getElementById('meshDisconnectBtn'); const disconnectBtn = document.getElementById('meshDisconnectBtn');
const nodeSection = document.getElementById('meshNodeSection'); const nodeSection = document.getElementById('meshNodeSection');
@@ -248,7 +302,9 @@ const Meshtastic = (function() {
const stripStatus = document.getElementById('meshStripStatus'); const stripStatus = document.getElementById('meshStripStatus');
if (connected) { if (connected) {
updateStatusIndicator('connected', device ? `Connected to ${device}` : 'Connected'); const connLabel = connectionType === 'tcp' ? 'TCP' : 'Serial';
const statusText = device ? `${device} (${connLabel})` : `Connected (${connLabel})`;
updateStatusIndicator('connected', statusText);
if (connectBtn) connectBtn.style.display = 'none'; if (connectBtn) connectBtn.style.display = 'none';
if (disconnectBtn) disconnectBtn.style.display = 'block'; if (disconnectBtn) disconnectBtn.style.display = 'block';
if (nodeSection) nodeSection.style.display = 'block'; if (nodeSection) nodeSection.style.display = 'block';
@@ -263,7 +319,7 @@ const Meshtastic = (function() {
if (stripDot) { if (stripDot) {
stripDot.className = 'mesh-strip-dot connected'; stripDot.className = 'mesh-strip-dot connected';
} }
if (stripStatus) stripStatus.textContent = device || 'Connected'; if (stripStatus) stripStatus.textContent = statusText;
} else { } else {
updateStatusIndicator('disconnected', 'Disconnected'); updateStatusIndicator('disconnected', 'Disconnected');
if (connectBtn) connectBtn.style.display = 'block'; if (connectBtn) connectBtn.style.display = 'block';
@@ -2200,6 +2256,7 @@ const Meshtastic = (function() {
init, init,
start, start,
stop, stop,
onConnectionTypeChange,
loadPorts, loadPorts,
refreshChannels, refreshChannels,
openChannelModal, openChannelModal,
+19 -6
View File
@@ -41,8 +41,13 @@ const SSTV = (function() {
const latInput = document.getElementById('sstvObsLat'); const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon'); const lonInput = document.getElementById('sstvObsLon');
const storedLat = localStorage.getItem('observerLat'); let storedLat = localStorage.getItem('observerLat');
const storedLon = localStorage.getItem('observerLon'); let storedLon = localStorage.getItem('observerLon');
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
storedLat = shared.lat.toString();
storedLon = shared.lon.toString();
}
if (latInput && storedLat) latInput.value = storedLat; if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon; if (lonInput && storedLon) lonInput.value = storedLon;
@@ -64,8 +69,12 @@ const SSTV = (function() {
if (!isNaN(lat) && lat >= -90 && lat <= 90 && if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
!isNaN(lon) && lon >= -180 && lon <= 180) { !isNaN(lon) && lon >= -180 && lon <= 180) {
localStorage.setItem('observerLat', lat.toString()); if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
localStorage.setItem('observerLon', lon.toString()); ObserverLocation.setShared({ lat, lon });
} else {
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
}
loadIssSchedule(); // Refresh pass predictions loadIssSchedule(); // Refresh pass predictions
} }
} }
@@ -94,8 +103,12 @@ const SSTV = (function() {
if (latInput) latInput.value = lat; if (latInput) latInput.value = lat;
if (lonInput) lonInput.value = lon; if (lonInput) lonInput.value = lon;
localStorage.setItem('observerLat', lat); if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
localStorage.setItem('observerLon', lon); ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
} else {
localStorage.setItem('observerLat', lat);
localStorage.setItem('observerLon', lon);
}
btn.innerHTML = originalText; btn.innerHTML = originalText;
btn.disabled = false; btn.disabled = false;
+106 -5
View File
@@ -77,6 +77,7 @@ const WiFiMode = (function() {
let scanMode = 'quick'; // 'quick' or 'deep' let scanMode = 'quick'; // 'quick' or 'deep'
let eventSource = null; let eventSource = null;
let pollTimer = null; let pollTimer = null;
let agentPollTimer = null;
// Data stores // Data stores
let networks = new Map(); // bssid -> network let networks = new Map(); // bssid -> network
@@ -505,8 +506,13 @@ const WiFiMode = (function() {
console.log('[WiFiMode] Agent deep scan started:', scanResult); console.log('[WiFiMode] Agent deep scan started:', scanResult);
} }
// Start SSE stream for real-time updates // Start SSE stream for real-time updates (works with push-enabled agents)
startEventStream(); startEventStream();
// Also start polling for agent data (works without push enabled)
if (isAgentMode) {
startAgentDeepScanPolling();
}
} catch (error) { } catch (error) {
console.error('[WiFiMode] Deep scan error:', error); console.error('[WiFiMode] Deep scan error:', error);
showError(error.message); showError(error.message);
@@ -523,6 +529,9 @@ const WiFiMode = (function() {
pollTimer = null; pollTimer = null;
} }
// Stop agent polling
stopAgentDeepScanPolling();
// Close event stream // Close event stream
if (eventSource) { if (eventSource) {
eventSource.close(); eventSource.close();
@@ -584,9 +593,18 @@ const WiFiMode = (function() {
const status = isAgentMode && data.result ? data.result : data; const status = isAgentMode && data.result ? data.result : data;
if (status.is_scanning || status.running) { if (status.is_scanning || status.running) {
setScanning(true, status.scan_mode); // Agent returns scan_type in params, local returns scan_mode
if (status.scan_mode === 'deep') { // Normalize: agent may return 'deepscan' or 'deep', UI expects 'deep' or 'quick'
let detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep';
if (detectedMode === 'deepscan') detectedMode = 'deep';
setScanning(true, detectedMode);
if (detectedMode === 'deep') {
startEventStream(); startEventStream();
// Also start polling for agent mode (works without push enabled)
if (isAgentMode) {
startAgentDeepScanPolling();
}
} else { } else {
startQuickScanPolling(); startQuickScanPolling();
} }
@@ -655,6 +673,76 @@ const WiFiMode = (function() {
}); });
} }
// ==========================================================================
// Agent Deep Scan Polling (fallback when push is not enabled)
// ==========================================================================
function startAgentDeepScanPolling() {
if (agentPollTimer) return;
console.log('[WiFiMode] Starting agent deep scan polling...');
agentPollTimer = setInterval(async () => {
if (!isScanning || scanMode !== 'deep') {
clearInterval(agentPollTimer);
agentPollTimer = null;
return;
}
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (!isAgentMode) {
clearInterval(agentPollTimer);
agentPollTimer = null;
return;
}
try {
const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`);
if (!response.ok) return;
const result = await response.json();
if (result.status !== 'success' || !result.data) return;
const data = result.data.data || result.data;
const agentName = result.agent_name || 'Remote';
// Process networks
if (data.networks && Array.isArray(data.networks)) {
data.networks.forEach(net => {
net._agent = agentName;
handleStreamEvent({
type: 'network_update',
network: net
});
});
}
// Process clients
if (data.clients && Array.isArray(data.clients)) {
data.clients.forEach(client => {
client._agent = agentName;
handleStreamEvent({
type: 'client_update',
client: client
});
});
}
console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`);
} catch (error) {
console.debug('[WiFiMode] Agent poll error:', error);
}
}, 2000); // Poll every 2 seconds
}
function stopAgentDeepScanPolling() {
if (agentPollTimer) {
clearInterval(agentPollTimer);
agentPollTimer = null;
}
}
// ========================================================================== // ==========================================================================
// SSE Event Stream // SSE Event Stream
// ========================================================================== // ==========================================================================
@@ -1292,9 +1380,19 @@ const WiFiMode = (function() {
console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId); console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId);
// Stop any running scan // Stop UI polling only - don't stop the actual scan on the agent
// The agent should continue running independently
if (isScanning) { if (isScanning) {
stopScan(); stopAgentDeepScanPolling();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
setScanning(false);
} }
// Clear existing data when switching agents (unless "Show All" is enabled) // Clear existing data when switching agents (unless "Show All" is enabled)
@@ -1306,6 +1404,9 @@ const WiFiMode = (function() {
// Refresh capabilities for new agent // Refresh capabilities for new agent
checkCapabilities(); checkCapabilities();
// Check if new agent already has a scan running
checkScanStatus();
lastAgentId = currentAgentId; lastAgentId = currentAgentId;
} }
+61 -24
View File
@@ -20,6 +20,11 @@
{% endif %} {% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head> </head>
<body> <body>
<div class="radar-bg"></div> <div class="radar-bg"></div>
@@ -264,6 +269,7 @@
<select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)"> <select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)">
<option value="0">SDR 0</option> <option value="0">SDR 0</option>
</select> </select>
<label class="bias-t-label" title="Enable Bias-T power for external LNA/preamp"><input type="checkbox" id="adsbBiasT" onchange="saveAdsbBiasTSetting()"> Bias-T</label>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button> <button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
</div> </div>
</div> </div>
@@ -317,10 +323,23 @@
<script> <script>
// ============================================ // ============================================
// BIAS-T HELPER (reads from main dashboard localStorage) // BIAS-T HELPER
// ============================================ // ============================================
function getBiasTEnabled() { function getBiasTEnabled() {
return localStorage.getItem('biasTEnabled') === 'true'; return document.getElementById('adsbBiasT')?.checked || false;
}
function saveAdsbBiasTSetting() {
const enabled = document.getElementById('adsbBiasT')?.checked || false;
localStorage.setItem('adsbBiasTEnabled', enabled);
}
function loadAdsbBiasTSetting() {
const saved = localStorage.getItem('adsbBiasTEnabled');
if (saved === 'true') {
const checkbox = document.getElementById('adsbBiasT');
if (checkbox) checkbox.checked = true;
}
} }
// ============================================ // ============================================
@@ -518,6 +537,9 @@
// Observer location and range rings (load from localStorage or default to London) // Observer location and range rings (load from localStorage or default to London)
let observerLocation = (function() { let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
}
const saved = localStorage.getItem('observerLocation'); const saved = localStorage.getItem('observerLocation');
if (saved) { if (saved) {
try { try {
@@ -1803,7 +1825,11 @@ ACARS: ${r.statistics.acarsMessages} messages`;
observerLocation.lon = lon; observerLocation.lon = lon;
// Save to localStorage for persistence // Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation)); if (window.ObserverLocation) {
ObserverLocation.setForModule('observerLocation', observerLocation);
} else {
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
}
if (radarMap) { if (radarMap) {
radarMap.setView([lat, lon], radarMap.getZoom()); radarMap.setView([lat, lon], radarMap.getZoom());
@@ -1831,7 +1857,11 @@ ACARS: ${r.statistics.acarsMessages} messages`;
observerLocation.lon = position.coords.longitude; observerLocation.lon = position.coords.longitude;
// Save to localStorage for persistence // Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation)); if (window.ObserverLocation) {
ObserverLocation.setForModule('observerLocation', observerLocation);
} else {
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
}
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4); document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4); document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
@@ -1925,6 +1955,9 @@ ACARS: ${r.statistics.acarsMessages} messages`;
observerLocation.lon = position.longitude; observerLocation.lon = position.longitude;
document.getElementById('obsLat').value = position.latitude.toFixed(4); document.getElementById('obsLat').value = position.latitude.toFixed(4);
document.getElementById('obsLon').value = position.longitude.toFixed(4); document.getElementById('obsLon').value = position.longitude.toFixed(4);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
}
// Center map on GPS location (on first fix) // Center map on GPS location (on first fix)
if (radarMap && !radarMap._gpsInitialized) { if (radarMap && !radarMap._gpsInitialized) {
@@ -1989,6 +2022,9 @@ ACARS: ${r.statistics.acarsMessages} messages`;
const detectionToggle = document.getElementById('detectionSoundToggle'); const detectionToggle = document.getElementById('detectionSoundToggle');
if (detectionToggle) detectionToggle.checked = detectionSoundEnabled; if (detectionToggle) detectionToggle.checked = detectionSoundEnabled;
// Load Bias-T setting from localStorage
loadAdsbBiasTSetting();
initMap(); initMap();
initDeviceSelectors(); initDeviceSelectors();
updateClock(); updateClock();
@@ -2027,27 +2063,18 @@ ACARS: ${r.statistics.acarsMessages} messages`;
} else { } else {
devices.forEach((dev, i) => { devices.forEach((dev, i) => {
const idx = dev.index !== undefined ? dev.index : i; const idx = dev.index !== undefined ? dev.index : i;
const displayName = `SDR ${idx}: ${dev.name}`;
// Build descriptive label
const type = dev.sdr_type || dev.driver || 'RTL-SDR';
const typeName = type.toUpperCase().replace('RTLSDR', 'RTL-SDR');
const shortSerial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
const displayName = `${typeName} #${idx}${shortSerial}`;
const fullName = dev.name || `${typeName} Device ${idx}`;
const tooltip = `${fullName}${dev.serial ? ' - Serial: ' + dev.serial : ''}`;
// Add to ADS-B selector // Add to ADS-B selector
const adsbOpt = document.createElement('option'); const adsbOpt = document.createElement('option');
adsbOpt.value = idx; adsbOpt.value = idx;
adsbOpt.textContent = displayName; adsbOpt.textContent = displayName;
adsbOpt.title = tooltip;
adsbSelect.appendChild(adsbOpt); adsbSelect.appendChild(adsbOpt);
// Add to Airband selector // Add to Airband selector
const airbandOpt = document.createElement('option'); const airbandOpt = document.createElement('option');
airbandOpt.value = idx; airbandOpt.value = idx;
airbandOpt.textContent = displayName; airbandOpt.textContent = displayName;
airbandOpt.title = tooltip;
airbandSelect.appendChild(airbandOpt); airbandSelect.appendChild(airbandOpt);
}); });
@@ -2537,9 +2564,13 @@ sudo make install</code>
try { try {
const response = await fetch('/adsb/session'); const response = await fetch('/adsb/session');
if (!response.ok) { if (!response.ok) {
// No session info - try to auto-start if SDR available // No session info - only auto-start if enabled
console.log('[ADS-B] No session found, attempting auto-start...'); if (window.INTERCEPT_ADSB_AUTO_START) {
await tryAutoStartLocal(); console.log('[ADS-B] No session found, attempting auto-start...');
await tryAutoStartLocal();
} else {
console.log('[ADS-B] No session found; auto-start disabled');
}
return; return;
} }
const data = await response.json(); const data = await response.json();
@@ -2581,15 +2612,21 @@ sudo make install</code>
const statusEl = document.getElementById('trackingStatus'); const statusEl = document.getElementById('trackingStatus');
statusEl.textContent = 'TRACKING'; statusEl.textContent = 'TRACKING';
} else { } else {
// Session not active - try to auto-start // Session not active - only auto-start if enabled
console.log('[ADS-B] No active session, attempting auto-start...'); if (window.INTERCEPT_ADSB_AUTO_START) {
await tryAutoStartLocal(); console.log('[ADS-B] No active session, attempting auto-start...');
await tryAutoStartLocal();
} else {
console.log('[ADS-B] No active session; auto-start disabled');
}
} }
} catch (err) { } catch (err) {
console.warn('[ADS-B] Failed to sync tracking status:', err); console.warn('[ADS-B] Failed to sync tracking status:', err);
// Try auto-start anyway // Try auto-start only if enabled
await tryAutoStartLocal(); if (window.INTERCEPT_ADSB_AUTO_START) {
await tryAutoStartLocal();
}
} }
} }
@@ -3975,7 +4012,7 @@ sudo make install</code>
devices.forEach((d, i) => { devices.forEach((d, i) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.index || i; opt.value = d.index || i;
opt.textContent = `Device ${d.index || i}: ${d.name || d.type || 'SDR'}`; opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt); select.appendChild(opt);
}); });
// Default to device 1 if available (device 0 likely used for ADS-B) // Default to device 1 if available (device 0 likely used for ADS-B)
@@ -4745,7 +4782,7 @@ sudo make install</code>
devices.forEach(device => { devices.forEach(device => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = device.index; opt.value = device.index;
opt.textContent = `Device ${device.index}: ${device.name || device.type || 'SDR'}`; opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`;
select.appendChild(opt); select.appendChild(opt);
}); });
} }
+1 -3
View File
@@ -558,11 +558,9 @@
} }
devices.forEach((dev, idx) => { devices.forEach((dev, idx) => {
const index = dev.index !== undefined ? dev.index : idx; const index = dev.index !== undefined ? dev.index : idx;
const type = (dev.sdr_type || dev.driver || 'RTL-SDR').toUpperCase();
const serial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = index; opt.value = index;
opt.textContent = `${type} #${index}${serial}`; opt.textContent = `SDR ${index}: ${dev.name}`;
sessionDeviceSelect.appendChild(opt); sessionDeviceSelect.appendChild(opt);
}); });
sessionDeviceSelect.disabled = false; sessionDeviceSelect.disabled = false;
+17
View File
@@ -337,6 +337,7 @@
<div class="form-group"> <div class="form-group">
<label for="agentApiKey">API Key (optional)</label> <label for="agentApiKey">API Key (optional)</label>
<input type="text" id="agentApiKey" placeholder="shared-secret"> <input type="text" id="agentApiKey" placeholder="shared-secret">
<small style="color: #888; font-size: 11px;">Required if agent has push mode enabled with API key</small>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
@@ -455,6 +456,22 @@
const apiKey = document.getElementById('agentApiKey').value.trim(); const apiKey = document.getElementById('agentApiKey').value.trim();
const description = document.getElementById('agentDescription').value.trim(); const description = document.getElementById('agentDescription').value.trim();
// Validate URL format
try {
const url = new URL(baseUrl);
if (!url.port && !baseUrl.includes(':80') && !baseUrl.includes(':443')) {
showToast('URL should include a port (e.g., http://192.168.1.50:8020)', 'error');
return;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
showToast('URL must start with http:// or https://', 'error');
return;
}
} catch (e) {
showToast('Invalid URL format. Use: http://IP_ADDRESS:PORT', 'error');
return;
}
try { try {
const response = await fetch('/controller/agents', { const response = await fetch('/controller/agents', {
method: 'POST', method: 'POST',
+23 -14
View File
@@ -20,6 +20,10 @@
{% endif %} {% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head> </head>
<body> <body>
<!-- Radar background effects --> <!-- Radar background effects -->
@@ -219,7 +223,12 @@
const MAX_TRAIL_POINTS = 50; const MAX_TRAIL_POINTS = 50;
// Observer location // Observer location
let observerLocation = { lat: 51.5074, lon: -0.1278 }; let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('ais_observerLocation');
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null; let rangeRingsLayer = null;
let observerMarker = null; let observerMarker = null;
@@ -376,17 +385,9 @@
// Initialize map // Initialize map
function initMap() { function initMap() {
// Load saved observer location if (observerLocation) {
const saved = localStorage.getItem('ais_observerLocation'); document.getElementById('obsLat').value = observerLocation.lat;
if (saved) { document.getElementById('obsLon').value = observerLocation.lon;
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) {
observerLocation = parsed;
document.getElementById('obsLat').value = parsed.lat;
document.getElementById('obsLon').value = parsed.lon;
}
} catch (e) {}
} }
vesselMap = L.map('vesselMap', { vesselMap = L.map('vesselMap', {
@@ -470,7 +471,11 @@
const lon = parseFloat(document.getElementById('obsLon').value); const lon = parseFloat(document.getElementById('obsLon').value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation = { lat, lon }; observerLocation = { lat, lon };
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation)); if (window.ObserverLocation) {
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
} else {
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
}
if (observerMarker) { if (observerMarker) {
observerMarker.setLatLng([lat, lon]); observerMarker.setLatLng([lat, lon]);
} }
@@ -1058,7 +1063,11 @@
drawRangeRings(); drawRangeRings();
// Save to localStorage // Save to localStorage
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation)); if (window.ObserverLocation) {
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
} else {
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
}
} }
function showGpsIndicator(show) { function showGpsIndicator(show) {
+193 -25
View File
@@ -14,6 +14,10 @@
window._showDisclaimerOnLoad = true; window._showDisclaimerOnLoad = true;
} }
</script> </script>
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
<!-- Fonts - Conditional CDN/Local loading --> <!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %} {% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
@@ -419,6 +423,7 @@
<a href="/controller/monitor" class="nav-tool-btn" title="Network Monitor - Multi-Agent View" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span></a> <a href="/controller/monitor" class="nav-tool-btn" title="Network Monitor - Multi-Agent View" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span></a>
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span></a> <a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span></a>
<button class="nav-tool-btn" onclick="showSettings()" title="Settings"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span></button> <button class="nav-tool-btn" onclick="showSettings()" title="Settings"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span></button>
<a href="https://buymeacoffee.com/smittix" target="_blank" rel="noopener noreferrer" class="nav-tool-btn nav-tool-btn--donate" title="Support the Project"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></span></a>
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button> <button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
<button class="nav-tool-btn" onclick="logout(event)" title="Logout"> <button class="nav-tool-btn" onclick="logout(event)" title="Logout">
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span> <span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
@@ -533,6 +538,14 @@
Refresh Devices Refresh Devices
</button> </button>
<!-- SDR Device Status -->
<div id="sdrStatusPanel" style="margin-top: 10px; border: 1px solid var(--border-color); border-radius: 4px;">
<div id="sdrStatusList" style="max-height: 150px; overflow-y: auto;"></div>
<div style="padding: 6px 8px; background: var(--bg-tertiary); border-top: 1px solid var(--border-color); font-size: 10px; color: #666;">
Auto-refreshes every 5s
</div>
</div>
<!-- Remote SDR (rtl_tcp) --> <!-- Remote SDR (rtl_tcp) -->
<div class="form-group" style="margin-top: 10px;"> <div class="form-group" style="margin-top: 10px;">
<label class="inline-checkbox"> <label class="inline-checkbox">
@@ -1625,9 +1638,14 @@
<span class="mesh-strip-dot disconnected" id="meshStripDot"></span> <span class="mesh-strip-dot disconnected" id="meshStripDot"></span>
<span class="mesh-strip-status-text" id="meshStripStatus">Disconnected</span> <span class="mesh-strip-status-text" id="meshStripStatus">Disconnected</span>
</div> </div>
<select id="meshStripConnType" class="mesh-strip-select" title="Connection Type" onchange="Meshtastic.onConnectionTypeChange()" style="width: 70px;">
<option value="serial">Serial</option>
<option value="tcp">TCP</option>
</select>
<select id="meshStripDevice" class="mesh-strip-select" title="Device"> <select id="meshStripDevice" class="mesh-strip-select" title="Device">
<option value="">Auto-detect</option> <option value="">Auto-detect</option>
</select> </select>
<input type="text" id="meshStripHostname" class="mesh-strip-input" placeholder="IP address" title="Hostname/IP for TCP" style="display: none; width: 120px;">
<button class="mesh-strip-btn connect" id="meshStripConnectBtn" onclick="Meshtastic.start()">Connect</button> <button class="mesh-strip-btn connect" id="meshStripConnectBtn" onclick="Meshtastic.start()">Connect</button>
<button class="mesh-strip-btn disconnect" id="meshStripDisconnectBtn" onclick="Meshtastic.stop()" style="display: none;">Disconnect</button> <button class="mesh-strip-btn disconnect" id="meshStripDisconnectBtn" onclick="Meshtastic.stop()" style="display: none;">Disconnect</button>
</div> </div>
@@ -2252,6 +2270,9 @@
// Observer location for distance calculations (load from localStorage or default to London) // Observer location for distance calculations (load from localStorage or default to London)
let observerLocation = (function () { let observerLocation = (function () {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
}
const saved = localStorage.getItem('observerLocation'); const saved = localStorage.getItem('observerLocation');
if (saved) { if (saved) {
try { try {
@@ -2406,6 +2427,19 @@
// Load bias-T setting from localStorage // Load bias-T setting from localStorage
loadBiasTSetting(); loadBiasTSetting();
// Initialize device list from server-provided data
// This ensures currentDeviceList is populated on page load (fixes #99)
if (typeof deviceList !== 'undefined' && deviceList.length > 0) {
currentDeviceList = deviceList;
const firstType = deviceList[0].sdr_type || 'rtlsdr';
const sdrTypeSelect = document.getElementById('sdrTypeSelect');
if (sdrTypeSelect) {
sdrTypeSelect.value = firstType;
}
// Defer onSDRTypeChanged to ensure DOM is ready
setTimeout(onSDRTypeChanged, 0);
}
// Initialize observer location input fields from saved location // Initialize observer location input fields from saved location
const obsLatInput = document.getElementById('obsLat'); const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon'); const obsLonInput = document.getElementById('obsLon');
@@ -2420,6 +2454,9 @@
// Initialize dropdown nav active state // Initialize dropdown nav active state
updateDropdownActiveState(); updateDropdownActiveState();
// Start SDR device status polling
startSdrStatusPolling();
}); });
// Toggle section collapse // Toggle section collapse
@@ -3004,7 +3041,18 @@
if (data.pressure_hPa !== undefined) { if (data.pressure_hPa !== undefined) {
msg.pressure = data.pressure_hPa; msg.pressure = data.pressure_hPa;
msg.pressure_unit = 'hPa'; msg.pressure_unit = 'hPa';
} else if (data.pressure_PSI !== undefined) {
msg.pressure = data.pressure_PSI;
msg.pressure_unit = 'PSI';
} else if (data.pressure_kPa !== undefined) {
msg.pressure = data.pressure_kPa;
msg.pressure_unit = 'kPa';
} else if (data.tire_pressure_kPa !== undefined) {
msg.pressure = data.tire_pressure_kPa;
msg.pressure_unit = 'kPa';
} }
if (data.flags !== undefined) msg.state = data.flags;
else if (data.state !== undefined) msg.state = data.state;
if (data.wind_avg_km_h !== undefined) { if (data.wind_avg_km_h !== undefined) {
msg.wind_speed = data.wind_avg_km_h; msg.wind_speed = data.wind_avg_km_h;
msg.wind_unit = 'km/h'; msg.wind_unit = 'km/h';
@@ -3681,6 +3729,9 @@
// Trigger filter update // Trigger filter update
onSDRTypeChanged(); onSDRTypeChanged();
// Also refresh SDR status panel
fetchSdrStatus();
}) })
.catch(err => { .catch(err => {
console.error('Failed to refresh devices:', err); console.error('Failed to refresh devices:', err);
@@ -3689,6 +3740,71 @@
}); });
} }
// SDR Device Status Panel
let sdrStatusPollingInterval = null;
function renderSdrStatus(devices) {
const container = document.getElementById('sdrStatusList');
if (!container) return;
if (!devices || devices.length === 0) {
container.innerHTML = '<div style="padding: 8px; color: #888; font-size: 11px; text-align: center;">No SDR devices detected</div>';
return;
}
const html = devices.map(d => {
const isActive = d.in_use;
const statusDot = isActive
? '<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #00ff88; box-shadow: 0 0 6px #00ff88; margin-right: 6px;"></span>'
: '<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #555; margin-right: 6px;"></span>';
const modeName = d.used_by ? d.used_by.toUpperCase() : 'IDLE';
const modeColor = isActive ? '#00ff88' : '#666';
const sdrType = (d.sdr_type || 'RTL').toUpperCase().replace('RTLSDR', 'RTL');
return `<div style="display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; align-items: center;">
${statusDot}
<span style="font-size: 11px;">#${d.index} ${d.name || 'Unknown'}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<span style="font-size: 10px; color: ${modeColor}; font-weight: bold;">${modeName}</span>
<span style="font-size: 9px; padding: 1px 4px; background: var(--bg-tertiary); border-radius: 3px; color: #888;">${sdrType}</span>
</div>
</div>`;
}).join('');
container.innerHTML = html;
}
function fetchSdrStatus() {
fetch('/devices/status')
.then(r => r.json())
.then(devices => {
renderSdrStatus(devices);
})
.catch(err => {
console.error('Failed to fetch SDR status:', err);
const container = document.getElementById('sdrStatusList');
if (container) {
container.innerHTML = '<div style="padding: 8px; color: #ff6666; font-size: 11px; text-align: center;">Error loading status</div>';
}
});
}
function startSdrStatusPolling() {
// Initial fetch
fetchSdrStatus();
// Poll every 5 seconds
sdrStatusPollingInterval = setInterval(fetchSdrStatus, 5000);
}
function stopSdrStatusPolling() {
if (sdrStatusPollingInterval) {
clearInterval(sdrStatusPollingInterval);
sdrStatusPollingInterval = null;
}
}
function getSelectedDevice() { function getSelectedDevice() {
return document.getElementById('deviceSelect').value; return document.getElementById('deviceSelect').value;
} }
@@ -5421,6 +5537,53 @@
const select = document.getElementById('wifiInterfaceSelect'); const select = document.getElementById('wifiInterfaceSelect');
select.innerHTML = '<option value="">Loading interfaces...</option>'; select.innerHTML = '<option value="">Loading interfaces...</option>';
// Check if we're in agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (isAgentMode) {
// Fetch from agent via controller
fetch(`/controller/agents/${currentAgent}?refresh=true`)
.then(r => {
if (!r.ok) throw new Error('Failed to fetch agent interfaces');
return r.json();
})
.then(data => {
const interfaces = data.agent?.interfaces?.wifi_interfaces || [];
if (interfaces.length === 0) {
select.innerHTML = '<option value="">No WiFi interfaces on agent</option>';
showNotification('WiFi', 'No WiFi interfaces found on remote agent.');
monitorInterface = null;
updateMonitorStatus(false);
} else {
select.innerHTML = interfaces.map(i => {
let label = i.name || i;
if (i.display_name) label = i.display_name;
else if (i.type) label += ` (${i.type})`;
if (i.monitor_capable) label += ' [Monitor OK]';
return `<option value="${i.name || i}" data-type="${i.type || 'managed'}">${label}</option>`;
}).join('');
showNotification('WiFi', `Found ${interfaces.length} interface(s) on agent`);
// Check if any interface is already in monitor mode
const monitorIface = interfaces.find(i => i.type === 'monitor');
if (monitorIface) {
monitorInterface = monitorIface.name;
updateMonitorStatus(true);
select.value = monitorIface.name;
} else {
monitorInterface = null;
updateMonitorStatus(false);
}
}
})
.catch(err => {
console.error('Failed to refresh agent interfaces:', err);
select.innerHTML = '<option value="">Error loading agent interfaces</option>';
showNotification('WiFi', 'Failed to load agent interfaces');
});
return;
}
fetch('/wifi/interfaces') fetch('/wifi/interfaces')
.then(r => { .then(r => {
if (!r.ok) throw new Error('Failed to fetch interfaces'); if (!r.ok) throw new Error('Failed to fetch interfaces');
@@ -5477,6 +5640,7 @@
} }
const killProcesses = document.getElementById('killProcesses').checked; const killProcesses = document.getElementById('killProcesses').checked;
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
// Show loading state // Show loading state
const btn = document.getElementById('monitorStartBtn'); const btn = document.getElementById('monitorStartBtn');
@@ -5484,7 +5648,12 @@
btn.textContent = 'Enabling...'; btn.textContent = 'Enabling...';
btn.disabled = true; btn.disabled = true;
fetch('/wifi/monitor', { // Use agent endpoint if in agent mode
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/wifi/monitor`
: '/wifi/monitor';
fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interface: iface, action: 'start', kill_processes: killProcesses }) body: JSON.stringify({ interface: iface, action: 'start', kill_processes: killProcesses })
@@ -5496,29 +5665,13 @@
if (data.status === 'success') { if (data.status === 'success') {
monitorInterface = data.monitor_interface; monitorInterface = data.monitor_interface;
updateMonitorStatus(true); updateMonitorStatus(true);
showInfo('Monitor mode enabled on ' + monitorInterface + ' - Ready to scan!'); const location = isAgentMode ? ' on remote agent' : '';
showInfo('Monitor mode enabled on ' + monitorInterface + location + ' - Ready to scan!');
// Refresh interface list and auto-select the monitor interface // Refresh interface list and auto-select the monitor interface
fetch('/wifi/interfaces') refreshWifiInterfaces();
.then(r => r.json())
.then(ifaceData => {
const select = document.getElementById('wifiInterfaceSelect');
if (ifaceData.interfaces.length > 0) {
select.innerHTML = ifaceData.interfaces.map(i => {
let label = i.name;
let details = [];
if (i.chipset) details.push(i.chipset);
else if (i.driver) details.push(i.driver);
if (i.mac) details.push(i.mac.substring(0, 8) + '...');
if (details.length > 0) label += ' - ' + details.join(' | ');
label += ` (${i.type})`;
if (i.monitor_capable) label += ' [Monitor OK]';
return `<option value="${i.name}" ${i.name === monitorInterface ? 'selected' : ''}>${label}</option>`;
}).join('');
}
});
} else { } else {
alert('Error: ' + data.message); alert('Error: ' + (data.message || 'Unknown error'));
} }
}) })
.catch(err => { .catch(err => {
@@ -5531,8 +5684,13 @@
// Disable monitor mode // Disable monitor mode
function disableMonitorMode() { function disableMonitorMode() {
const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value; const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value;
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
fetch('/wifi/monitor', { const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/wifi/monitor`
: '/wifi/monitor';
fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interface: iface, action: 'stop' }) body: JSON.stringify({ interface: iface, action: 'stop' })
@@ -5543,7 +5701,7 @@
updateMonitorStatus(false); updateMonitorStatus(false);
showInfo('Monitor mode disabled'); showInfo('Monitor mode disabled');
} else { } else {
alert('Error: ' + data.message); alert('Error: ' + (data.message || 'Unknown error'));
} }
}); });
} }
@@ -8364,8 +8522,15 @@
if (navigator.geolocation) { if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
position => { position => {
document.getElementById('obsLat').value = position.coords.latitude.toFixed(4); const lat = position.coords.latitude;
document.getElementById('obsLon').value = position.coords.longitude.toFixed(4); const lon = position.coords.longitude;
document.getElementById('obsLat').value = lat.toFixed(4);
document.getElementById('obsLon').value = lon.toFixed(4);
observerLocation.lat = lat;
observerLocation.lon = lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
}
showInfo('Location updated!'); showInfo('Location updated!');
}, },
error => { error => {
@@ -8465,6 +8630,9 @@
// Update observerLocation // Update observerLocation
observerLocation.lat = position.latitude; observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude; observerLocation.lon = position.longitude;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
}
// Update APRS user location // Update APRS user location
updateAprsUserLocation(position); updateAprsUserLocation(position);
+19 -1
View File
@@ -291,10 +291,28 @@
WiFi/Bluetooth scanning, and more. WiFi/Bluetooth scanning, and more.
</p> </p>
<p> <p>
<a href="https://github.com/intercept" target="_blank">GitHub Repository</a> <a href="https://github.com/smittix/intercept" target="_blank">GitHub Repository</a>
</p> </p>
</div> </div>
</div> </div>
<div class="settings-group">
<div class="settings-group-title">Support the Project</div>
<p style="color: var(--text-dim); margin-bottom: 15px; font-size: 12px;">
If you find iNTERCEPT useful, consider supporting its development.
</p>
<a href="https://buymeacoffee.com/smittix" target="_blank" rel="noopener noreferrer" class="donate-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 18px; height: 18px; vertical-align: -3px; margin-right: 8px;">
<path d="M17 8h1a4 4 0 1 1 0 8h-1"/>
<path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/>
<line x1="6" y1="2" x2="6" y2="4"/>
<line x1="10" y1="2" x2="10" y2="4"/>
<line x1="14" y1="2" x2="14" y2="4"/>
</svg>
Buy Me a Coffee
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
+38 -3
View File
@@ -20,6 +20,10 @@
{% endif %} {% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head> </head>
<body> <body>
<div class="grid-bg"></div> <div class="grid-bg"></div>
@@ -313,15 +317,32 @@
} }
} }
function applySharedObserverLocation() {
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
if (shared) {
const latInput = document.getElementById('obsLat');
const lonInput = document.getElementById('obsLon');
if (latInput) latInput.value = shared.lat.toFixed(4);
if (lonInput) lonInput.value = shared.lon.toFixed(4);
return true;
}
}
return false;
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
setupEmbeddedMode(); setupEmbeddedMode();
const usedShared = applySharedObserverLocation();
initGroundMap(); initGroundMap();
updateClock(); updateClock();
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
setInterval(updateCountdown, 1000); setInterval(updateCountdown, 1000);
setInterval(updateRealTimePositions, 5000); setInterval(updateRealTimePositions, 5000);
loadAgents(); loadAgents();
getLocation(); if (!usedShared) {
getLocation();
}
}); });
async function loadAgents() { async function loadAgents() {
@@ -380,6 +401,9 @@
const gps = agentStatus.gps_position; const gps = agentStatus.gps_position;
document.getElementById('obsLat').value = gps.lat.toFixed(4); document.getElementById('obsLat').value = gps.lat.toFixed(4);
document.getElementById('obsLon').value = gps.lon.toFixed(4); document.getElementById('obsLon').value = gps.lon.toFixed(4);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: gps.lat, lon: gps.lon });
}
// Update observer marker label // Update observer marker label
const agent = agents.find(a => a.id == agentId); const agent = agents.find(a => a.id == agentId);
@@ -428,13 +452,24 @@
subdomains: 'abcd' subdomains: 'abcd'
}).addTo(groundMap); }).addTo(groundMap);
} }
const lat = parseFloat(document.getElementById('obsLat')?.value);
const lon = parseFloat(document.getElementById('obsLon')?.value);
if (!Number.isNaN(lat) && !Number.isNaN(lon)) {
groundMap.setView([lat, lon], 3);
}
} }
function getLocation() { function getLocation() {
if (navigator.geolocation) { if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => { navigator.geolocation.getCurrentPosition(pos => {
document.getElementById('obsLat').value = pos.coords.latitude.toFixed(4); const lat = pos.coords.latitude;
document.getElementById('obsLon').value = pos.coords.longitude.toFixed(4); const lon = pos.coords.longitude;
document.getElementById('obsLat').value = lat.toFixed(4);
document.getElementById('obsLon').value = lon.toFixed(4);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
}
calculatePasses(); calculatePasses();
}, () => { }, () => {
calculatePasses(); calculatePasses();
+587
View File
@@ -0,0 +1,587 @@
"""
Unit tests for deauthentication attack detector.
"""
import os
import sys
import time
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from utils.wifi.deauth_detector import (
DeauthDetector,
DeauthPacketInfo,
DeauthTracker,
DeauthAlert,
DEAUTH_REASON_CODES,
)
from utils.constants import (
DEAUTH_DETECTION_WINDOW,
DEAUTH_ALERT_THRESHOLD,
DEAUTH_CRITICAL_THRESHOLD,
)
class TestDeauthPacketInfo:
"""Tests for DeauthPacketInfo dataclass."""
def test_creation(self):
"""Test basic creation of packet info."""
pkt = DeauthPacketInfo(
timestamp=1234567890.0,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
signal_dbm=-45,
)
assert pkt.frame_type == 'deauth'
assert pkt.src_mac == 'AA:BB:CC:DD:EE:FF'
assert pkt.reason_code == 7
assert pkt.signal_dbm == -45
class TestDeauthTracker:
"""Tests for DeauthTracker."""
def test_add_packet(self):
"""Test adding packets to tracker."""
tracker = DeauthTracker()
pkt1 = DeauthPacketInfo(
timestamp=100.0,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
)
tracker.add_packet(pkt1)
assert len(tracker.packets) == 1
assert tracker.first_seen == 100.0
assert tracker.last_seen == 100.0
def test_multiple_packets(self):
"""Test adding multiple packets."""
tracker = DeauthTracker()
for i in range(5):
pkt = DeauthPacketInfo(
timestamp=100.0 + i,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
)
tracker.add_packet(pkt)
assert len(tracker.packets) == 5
assert tracker.first_seen == 100.0
assert tracker.last_seen == 104.0
def test_get_packets_in_window(self):
"""Test filtering packets by time window."""
tracker = DeauthTracker()
now = time.time()
# Add old packet
tracker.add_packet(DeauthPacketInfo(
timestamp=now - 10,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
# Add recent packets
for i in range(3):
tracker.add_packet(DeauthPacketInfo(
timestamp=now - i,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
# 5-second window should only include the 3 recent packets
in_window = tracker.get_packets_in_window(5.0)
assert len(in_window) == 3
def test_cleanup_old_packets(self):
"""Test removing old packets."""
tracker = DeauthTracker()
now = time.time()
# Add old packet
tracker.add_packet(DeauthPacketInfo(
timestamp=now - 20,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
# Add recent packet
tracker.add_packet(DeauthPacketInfo(
timestamp=now,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
tracker.alert_sent = True
# Cleanup with 10-second window
tracker.cleanup_old_packets(10.0)
assert len(tracker.packets) == 1
assert tracker.packets[0].timestamp == now
def test_cleanup_resets_alert_sent(self):
"""Test that cleanup resets alert_sent when all packets removed."""
tracker = DeauthTracker()
now = time.time()
tracker.add_packet(DeauthPacketInfo(
timestamp=now - 100, # Very old
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
tracker.alert_sent = True
# Cleanup should remove all packets
tracker.cleanup_old_packets(10.0)
assert len(tracker.packets) == 0
assert tracker.alert_sent is False
class TestDeauthAlert:
"""Tests for DeauthAlert."""
def test_to_dict(self):
"""Test conversion to dictionary."""
alert = DeauthAlert(
id='deauth-123-1',
timestamp=1234567890.0,
severity='high',
attacker_mac='AA:BB:CC:DD:EE:FF',
attacker_vendor='Unknown',
attacker_signal_dbm=-45,
is_spoofed_ap=True,
target_mac='11:22:33:44:55:66',
target_vendor='Apple',
target_type='client',
target_known_from_scan=True,
ap_bssid='AA:BB:CC:DD:EE:FF',
ap_essid='TestNetwork',
ap_channel=6,
frame_type='deauth',
reason_code=7,
reason_text='Class 3 frame received from nonassociated STA',
packet_count=50,
window_seconds=5.0,
packets_per_second=10.0,
attack_type='targeted',
description='Targeted deauth flood against known client',
)
d = alert.to_dict()
assert d['id'] == 'deauth-123-1'
assert d['type'] == 'deauth_alert'
assert d['severity'] == 'high'
assert d['attacker']['mac'] == 'AA:BB:CC:DD:EE:FF'
assert d['attacker']['is_spoofed_ap'] is True
assert d['target']['type'] == 'client'
assert d['access_point']['essid'] == 'TestNetwork'
assert d['attack_info']['packet_count'] == 50
assert d['analysis']['attack_type'] == 'targeted'
class TestDeauthDetector:
"""Tests for DeauthDetector."""
def test_init(self):
"""Test detector initialization."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
assert detector.interface == 'wlan0mon'
assert detector.event_callback == callback
assert not detector.is_running
def test_stats(self):
"""Test stats property."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
stats = detector.stats
assert stats['is_running'] is False
assert stats['interface'] == 'wlan0mon'
assert stats['packets_captured'] == 0
assert stats['alerts_generated'] == 0
def test_get_alerts_empty(self):
"""Test getting alerts when none exist."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
alerts = detector.get_alerts()
assert alerts == []
def test_clear_alerts(self):
"""Test clearing alerts."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Add a mock alert
detector._alerts.append(MagicMock())
detector._trackers[('A', 'B', 'C')] = DeauthTracker()
detector._alert_counter = 5
detector.clear_alerts()
assert len(detector._alerts) == 0
assert len(detector._trackers) == 0
assert detector._alert_counter == 0
@patch('utils.wifi.deauth_detector.time.time')
def test_generate_alert_severity_low(self, mock_time):
"""Test alert generation with low severity."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Create packets just at threshold
packets = []
for i in range(DEAUTH_ALERT_THRESHOLD):
packets.append(DeauthPacketInfo(
timestamp=1000.0 - (DEAUTH_ALERT_THRESHOLD - 1 - i) * 0.1,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
signal_dbm=-50,
))
alert = detector._generate_alert(
tracker_key=('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44'),
packets=packets,
packet_count=DEAUTH_ALERT_THRESHOLD,
)
assert alert.severity == 'low'
assert alert.packet_count == DEAUTH_ALERT_THRESHOLD
@patch('utils.wifi.deauth_detector.time.time')
def test_generate_alert_severity_high(self, mock_time):
"""Test alert generation with high severity."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Create packets above critical threshold
packets = []
for i in range(DEAUTH_CRITICAL_THRESHOLD):
packets.append(DeauthPacketInfo(
timestamp=1000.0 - (DEAUTH_CRITICAL_THRESHOLD - 1 - i) * 0.1,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
))
alert = detector._generate_alert(
tracker_key=('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44'),
packets=packets,
packet_count=DEAUTH_CRITICAL_THRESHOLD,
)
assert alert.severity == 'high'
@patch('utils.wifi.deauth_detector.time.time')
def test_generate_alert_broadcast_attack(self, mock_time):
"""Test alert classification for broadcast attack."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
packets = [DeauthPacketInfo(
timestamp=999.9,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='FF:FF:FF:FF:FF:FF', # Broadcast
bssid='99:88:77:66:55:44',
reason_code=7,
)]
alert = detector._generate_alert(
tracker_key=('AA:BB:CC:DD:EE:FF', 'FF:FF:FF:FF:FF:FF', '99:88:77:66:55:44'),
packets=packets,
packet_count=10,
)
assert alert.attack_type == 'broadcast'
assert alert.target_type == 'broadcast'
assert 'all clients' in alert.description.lower()
def test_lookup_ap_no_callback(self):
"""Test AP lookup when no callback is provided."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
get_networks=None,
)
result = detector._lookup_ap('AA:BB:CC:DD:EE:FF')
assert result['bssid'] == 'AA:BB:CC:DD:EE:FF'
assert result['essid'] is None
assert result['channel'] is None
def test_lookup_ap_with_callback(self):
"""Test AP lookup with callback."""
callback = MagicMock()
get_networks = MagicMock(return_value={
'AA:BB:CC:DD:EE:FF': {'essid': 'TestNet', 'channel': 6}
})
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
get_networks=get_networks,
)
result = detector._lookup_ap('AA:BB:CC:DD:EE:FF')
assert result['bssid'] == 'AA:BB:CC:DD:EE:FF'
assert result['essid'] == 'TestNet'
assert result['channel'] == 6
def test_check_spoofed_source(self):
"""Test detection of spoofed AP source."""
callback = MagicMock()
get_networks = MagicMock(return_value={
'AA:BB:CC:DD:EE:FF': {'essid': 'TestNet'}
})
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
get_networks=get_networks,
)
# Source matches known AP - spoofed
assert detector._check_spoofed_source('AA:BB:CC:DD:EE:FF') is True
# Source does not match any AP - not spoofed
assert detector._check_spoofed_source('11:22:33:44:55:66') is False
def test_cleanup_old_trackers(self):
"""Test cleanup of old trackers."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
now = time.time()
# Add an old tracker
old_tracker = DeauthTracker()
old_tracker.add_packet(DeauthPacketInfo(
timestamp=now - 100, # Very old
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
))
detector._trackers[('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44')] = old_tracker
# Add a recent tracker
recent_tracker = DeauthTracker()
recent_tracker.add_packet(DeauthPacketInfo(
timestamp=now,
frame_type='deauth',
src_mac='BB:CC:DD:EE:FF:AA',
dst_mac='22:33:44:55:66:77',
bssid='88:77:66:55:44:33',
reason_code=7,
))
detector._trackers[('BB:CC:DD:EE:FF:AA', '22:33:44:55:66:77', '88:77:66:55:44:33')] = recent_tracker
detector._cleanup_old_trackers()
# Old tracker should be removed
assert ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44') not in detector._trackers
# Recent tracker should remain
assert ('BB:CC:DD:EE:FF:AA', '22:33:44:55:66:77', '88:77:66:55:44:33') in detector._trackers
class TestReasonCodes:
"""Tests for reason code dictionary."""
def test_common_reason_codes(self):
"""Test that common reason codes are defined."""
assert 1 in DEAUTH_REASON_CODES # Unspecified
assert 7 in DEAUTH_REASON_CODES # Class 3 frame
assert 14 in DEAUTH_REASON_CODES # MIC failure
def test_reason_code_descriptions(self):
"""Test reason code descriptions are strings."""
for code, desc in DEAUTH_REASON_CODES.items():
assert isinstance(code, int)
assert isinstance(desc, str)
assert len(desc) > 0
class TestDeauthDetectorIntegration:
"""Integration tests for DeauthDetector with mocked scapy."""
@patch('utils.wifi.deauth_detector.time.time')
def test_process_deauth_packet_generates_alert(self, mock_time):
"""Test that processing packets generates alert when threshold exceeded."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Create a mock scapy packet
mock_pkt = MagicMock()
# Mock Dot11Deauth layer
mock_deauth = MagicMock()
mock_deauth.reason = 7
# Mock Dot11 layer
mock_dot11 = MagicMock()
mock_dot11.addr1 = '11:22:33:44:55:66' # dst
mock_dot11.addr2 = 'AA:BB:CC:DD:EE:FF' # src
mock_dot11.addr3 = '99:88:77:66:55:44' # bssid
# Mock RadioTap layer
mock_radiotap = MagicMock()
mock_radiotap.dBm_AntSignal = -50
# Set up haslayer behavior
def haslayer_side_effect(layer):
if 'Dot11Deauth' in str(layer):
return True
if 'Dot11Disas' in str(layer):
return False
if 'RadioTap' in str(layer):
return True
return False
mock_pkt.haslayer = haslayer_side_effect
# Set up __getitem__ behavior
def getitem_side_effect(layer):
if 'Dot11Deauth' in str(layer):
return mock_deauth
if 'Dot11' in str(layer) and 'Deauth' not in str(layer):
return mock_dot11
if 'RadioTap' in str(layer):
return mock_radiotap
return MagicMock()
mock_pkt.__getitem__ = getitem_side_effect
# Patch the scapy imports inside _process_deauth_packet
with patch('utils.wifi.deauth_detector.DeauthDetector._process_deauth_packet.__globals__', {
'Dot11': MagicMock,
'Dot11Deauth': MagicMock,
'Dot11Disas': MagicMock,
'RadioTap': MagicMock,
}):
# Process enough packets to trigger alert
for i in range(DEAUTH_ALERT_THRESHOLD + 5):
mock_time.return_value = 1000.0 + i * 0.1
# Manually simulate what _process_deauth_packet does
pkt_info = DeauthPacketInfo(
timestamp=mock_time.return_value,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
signal_dbm=-50,
)
detector._packets_captured += 1
tracker_key = ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44')
tracker = detector._trackers[tracker_key]
tracker.add_packet(pkt_info)
packets_in_window = tracker.get_packets_in_window(DEAUTH_DETECTION_WINDOW)
packet_count = len(packets_in_window)
if packet_count >= DEAUTH_ALERT_THRESHOLD and not tracker.alert_sent:
alert = detector._generate_alert(
tracker_key=tracker_key,
packets=packets_in_window,
packet_count=packet_count,
)
detector._alerts.append(alert)
detector._alerts_generated += 1
tracker.alert_sent = True
detector.event_callback(alert.to_dict())
# Verify alert was generated
assert detector._alerts_generated == 1
assert len(detector._alerts) == 1
assert callback.called
# Verify callback was called with alert data
call_args = callback.call_args[0][0]
assert call_args['type'] == 'deauth_alert'
assert call_args['attacker']['mac'] == 'AA:BB:CC:DD:EE:FF'
assert call_args['target']['mac'] == '11:22:33:44:55:66'
+4
View File
@@ -141,6 +141,10 @@ class AgentClient:
except requests.RequestException as e: except requests.RequestException as e:
raise AgentHTTPError(f"Request failed: {e}") raise AgentHTTPError(f"Request failed: {e}")
def post(self, path: str, data: dict | None = None) -> dict:
"""Public POST method for arbitrary endpoints."""
return self._post(path, data)
# ========================================================================= # =========================================================================
# Capability & Status # Capability & Status
# ========================================================================= # =========================================================================
+20
View File
@@ -254,3 +254,23 @@ MAX_DSC_MESSAGE_AGE_SECONDS = 3600 # 1 hour
# DSC process termination timeout # DSC process termination timeout
DSC_TERMINATE_TIMEOUT = 3 DSC_TERMINATE_TIMEOUT = 3
# =============================================================================
# DEAUTH ATTACK DETECTION
# =============================================================================
# Time window for grouping deauth packets (seconds)
DEAUTH_DETECTION_WINDOW = 5
# Number of deauth packets in window to trigger alert
DEAUTH_ALERT_THRESHOLD = 10
# Number of deauth packets in window for critical severity
DEAUTH_CRITICAL_THRESHOLD = 50
# Maximum age for deauth alerts in DataStore (seconds)
MAX_DEAUTH_ALERTS_AGE_SECONDS = 300 # 5 minutes
# Deauth detector sniff timeout (seconds)
DEAUTH_SNIFF_TIMEOUT = 0.5
+42 -13
View File
@@ -3,7 +3,10 @@
This module provides integration with Meshtastic mesh networking devices, This module provides integration with Meshtastic mesh networking devices,
allowing INTERCEPT to receive and decode messages from LoRa mesh networks. allowing INTERCEPT to receive and decode messages from LoRa mesh networks.
Requires a physical Meshtastic device connected via USB/Serial. Supports multiple connection types:
- USB/Serial: Physical device connected via USB
- TCP: WiFi-enabled devices (T-Beam, Heltec WiFi LoRa, etc.)
Install SDK with: pip install meshtastic Install SDK with: pip install meshtastic
""" """
@@ -28,6 +31,7 @@ logger = get_logger('intercept.meshtastic')
try: try:
import meshtastic import meshtastic
import meshtastic.serial_interface import meshtastic.serial_interface
import meshtastic.tcp_interface
from meshtastic import BROADCAST_ADDR from meshtastic import BROADCAST_ADDR
from pubsub import pub from pubsub import pub
HAS_MESHTASTIC = True HAS_MESHTASTIC = True
@@ -278,6 +282,7 @@ class MeshtasticClient:
self._lock = threading.Lock() self._lock = threading.Lock()
self._nodes: dict[int, MeshNode] = {} # num -> MeshNode self._nodes: dict[int, MeshNode] = {} # num -> MeshNode
self._device_path: str | None = None self._device_path: str | None = None
self._connection_type: str | None = None # 'serial' or 'tcp'
self._error: str | None = None self._error: str | None = None
self._traceroute_results: list[TracerouteResult] = [] self._traceroute_results: list[TracerouteResult] = []
self._max_traceroute_results = 50 self._max_traceroute_results = 50
@@ -309,6 +314,10 @@ class MeshtasticClient:
def device_path(self) -> str | None: def device_path(self) -> str | None:
return self._device_path return self._device_path
@property
def connection_type(self) -> str | None:
return self._connection_type
@property @property
def error(self) -> str | None: def error(self) -> str | None:
return self._error return self._error
@@ -317,13 +326,16 @@ class MeshtasticClient:
"""Set callback for received messages.""" """Set callback for received messages."""
self._callback = callback self._callback = callback
def connect(self, device: str | None = None) -> bool: def connect(self, device: str | None = None, connection_type: str = 'serial',
hostname: str | None = None) -> bool:
""" """
Connect to a Meshtastic device. Connect to a Meshtastic device.
Args: Args:
device: Serial port path (e.g., /dev/ttyUSB0, /dev/ttyACM0). device: Serial port path (e.g., /dev/ttyUSB0, /dev/ttyACM0).
If None, auto-discovers first available device. Only used for serial connections. If None, auto-discovers.
connection_type: Connection type - 'serial' or 'tcp' (default: 'serial')
hostname: Hostname or IP address for TCP connections (e.g., '192.168.1.100')
Returns: Returns:
True if connected successfully. True if connected successfully.
@@ -342,18 +354,30 @@ class MeshtasticClient:
pub.subscribe(self._on_connection, "meshtastic.connection.established") pub.subscribe(self._on_connection, "meshtastic.connection.established")
pub.subscribe(self._on_disconnect, "meshtastic.connection.lost") pub.subscribe(self._on_disconnect, "meshtastic.connection.lost")
# Connect to device # Connect based on connection type
if device: if connection_type == 'tcp':
self._interface = meshtastic.serial_interface.SerialInterface(device) if not hostname:
self._device_path = device self._error = "Hostname is required for TCP connections"
self._cleanup_subscriptions()
return False
self._interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname)
self._device_path = hostname
self._connection_type = 'tcp'
logger.info(f"Connected to Meshtastic device via TCP: {hostname}")
else: else:
# Auto-discover # Serial connection (default)
self._interface = meshtastic.serial_interface.SerialInterface() if device:
self._device_path = "auto" self._interface = meshtastic.serial_interface.SerialInterface(device)
self._device_path = device
else:
# Auto-discover
self._interface = meshtastic.serial_interface.SerialInterface()
self._device_path = "auto"
self._connection_type = 'serial'
logger.info(f"Connected to Meshtastic device via serial: {self._device_path}")
self._running = True self._running = True
self._error = None self._error = None
logger.info(f"Connected to Meshtastic device: {self._device_path}")
return True return True
except Exception as e: except Exception as e:
@@ -375,6 +399,7 @@ class MeshtasticClient:
self._cleanup_subscriptions() self._cleanup_subscriptions()
self._running = False self._running = False
self._device_path = None self._device_path = None
self._connection_type = None
logger.info("Disconnected from Meshtastic device") logger.info("Disconnected from Meshtastic device")
def _cleanup_subscriptions(self) -> None: def _cleanup_subscriptions(self) -> None:
@@ -1502,13 +1527,17 @@ def get_meshtastic_client() -> MeshtasticClient | None:
def start_meshtastic(device: str | None = None, def start_meshtastic(device: str | None = None,
callback: Callable[[MeshtasticMessage], None] | None = None) -> bool: callback: Callable[[MeshtasticMessage], None] | None = None,
connection_type: str = 'serial',
hostname: str | None = None) -> bool:
""" """
Start the Meshtastic client. Start the Meshtastic client.
Args: Args:
device: Serial port path (optional, auto-discovers if not provided) device: Serial port path (optional, auto-discovers if not provided)
callback: Function to call when messages are received callback: Function to call when messages are received
connection_type: Connection type - 'serial' or 'tcp' (default: 'serial')
hostname: Hostname or IP address for TCP connections
Returns: Returns:
True if started successfully True if started successfully
@@ -1522,7 +1551,7 @@ def start_meshtastic(device: str | None = None,
if callback: if callback:
_client.set_callback(callback) _client.set_callback(callback)
return _client.connect(device) return _client.connect(device, connection_type=connection_type, hostname=hostname)
def stop_meshtastic() -> None: def stop_meshtastic() -> None:
+41 -1
View File
@@ -7,11 +7,44 @@ with existing RTL-SDR installations. No SoapySDR dependency required.
from __future__ import annotations from __future__ import annotations
import logging
import subprocess
from typing import Optional from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from utils.dependencies import get_tool_path from utils.dependencies import get_tool_path
logger = logging.getLogger('intercept.sdr.rtlsdr')
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
"""Detect the correct bias-t flag for the installed dump1090 variant.
Different dump1090 forks use different flags:
- dump1090-fa, readsb: --enable-biast (no hyphen before 't')
- dump1090-mutability, original dump1090: no bias-t support
Returns the correct flag string or None if bias-t is not supported.
"""
try:
result = subprocess.run(
[dump1090_path, '--help'],
capture_output=True,
text=True,
timeout=5
)
help_text = result.stdout + result.stderr
# Check for dump1090-fa/readsb style flag (no hyphen)
if '--enable-biast' in help_text:
return '--enable-biast'
# No bias-t support found
return None
except Exception as e:
logger.warning(f"Could not detect dump1090 bias-t support: {e}")
return None
class RTLSDRCommandBuilder(CommandBuilder): class RTLSDRCommandBuilder(CommandBuilder):
"""RTL-SDR command builder using native rtl_* tools.""" """RTL-SDR command builder using native rtl_* tools."""
@@ -113,7 +146,14 @@ class RTLSDRCommandBuilder(CommandBuilder):
cmd.extend(['--gain', str(int(gain))]) cmd.extend(['--gain', str(int(gain))])
if bias_t: if bias_t:
cmd.extend(['--enable-bias-t']) bias_t_flag = _get_dump1090_bias_t_flag(dump1090_path)
if bias_t_flag:
cmd.append(bias_t_flag)
else:
logger.warning(
f"Bias-t requested but {dump1090_path} does not support it. "
"Consider using dump1090-fa or readsb for bias-t support."
)
return cmd return cmd
+322 -18
View File
@@ -4,6 +4,8 @@ This module provides SSTV decoding capabilities for receiving images
from the International Space Station during special events. from the International Space Station during special events.
ISS SSTV typically transmits on 145.800 MHz FM. ISS SSTV typically transmits on 145.800 MHz FM.
Includes real-time Doppler shift compensation for improved reception.
""" """
from __future__ import annotations from __future__ import annotations
@@ -14,7 +16,7 @@ import subprocess
import threading import threading
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
@@ -25,10 +27,151 @@ logger = get_logger('intercept.sstv')
# ISS SSTV frequency # ISS SSTV frequency
ISS_SSTV_FREQ = 145.800 # MHz ISS_SSTV_FREQ = 145.800 # MHz
# Speed of light in m/s
SPEED_OF_LIGHT = 299_792_458
# Common SSTV modes used by ISS # Common SSTV modes used by ISS
SSTV_MODES = ['PD120', 'PD180', 'Martin1', 'Martin2', 'Scottie1', 'Scottie2', 'Robot36'] SSTV_MODES = ['PD120', 'PD180', 'Martin1', 'Martin2', 'Scottie1', 'Scottie2', 'Robot36']
@dataclass
class DopplerInfo:
"""Doppler shift information."""
frequency_hz: float # Doppler-corrected frequency in Hz
shift_hz: float # Doppler shift in Hz (positive = approaching)
range_rate_km_s: float # Range rate in km/s (negative = approaching)
elevation: float # Current elevation in degrees
azimuth: float # Current azimuth in degrees
timestamp: datetime
def to_dict(self) -> dict:
return {
'frequency_hz': self.frequency_hz,
'shift_hz': round(self.shift_hz, 1),
'range_rate_km_s': round(self.range_rate_km_s, 3),
'elevation': round(self.elevation, 1),
'azimuth': round(self.azimuth, 1),
'timestamp': self.timestamp.isoformat(),
}
class DopplerTracker:
"""
Real-time Doppler shift calculator for satellite tracking.
Uses skyfield to calculate the range rate between observer and satellite,
then computes the Doppler-shifted receive frequency.
"""
def __init__(self, satellite_name: str = 'ISS'):
self._satellite_name = satellite_name
self._observer_lat: float | None = None
self._observer_lon: float | None = None
self._satellite = None
self._observer = None
self._ts = None
self._enabled = False
def configure(self, latitude: float, longitude: float) -> bool:
"""
Configure the Doppler tracker with observer location.
Args:
latitude: Observer latitude in degrees
longitude: Observer longitude in degrees
Returns:
True if configured successfully
"""
try:
from skyfield.api import load, wgs84, EarthSatellite
from data.satellites import TLE_SATELLITES
# Get satellite TLE
tle_data = TLE_SATELLITES.get(self._satellite_name)
if not tle_data:
logger.error(f"No TLE data for satellite: {self._satellite_name}")
return False
self._ts = load.timescale()
self._satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], self._ts)
self._observer = wgs84.latlon(latitude, longitude)
self._observer_lat = latitude
self._observer_lon = longitude
self._enabled = True
logger.info(f"Doppler tracker configured for {self._satellite_name} at ({latitude}, {longitude})")
return True
except ImportError:
logger.warning("skyfield not available - Doppler tracking disabled")
return False
except Exception as e:
logger.error(f"Failed to configure Doppler tracker: {e}")
return False
@property
def is_enabled(self) -> bool:
return self._enabled
def calculate(self, nominal_freq_mhz: float) -> DopplerInfo | None:
"""
Calculate current Doppler-shifted frequency.
Args:
nominal_freq_mhz: Nominal transmit frequency in MHz
Returns:
DopplerInfo with corrected frequency, or None if unavailable
"""
if not self._enabled or not self._satellite or not self._observer:
return None
try:
# Get current time
t = self._ts.now()
# Calculate satellite position relative to observer
difference = self._satellite - self._observer
topocentric = difference.at(t)
# Get altitude/azimuth
alt, az, distance = topocentric.altaz()
# Get velocity (range rate) - negative means approaching
# We need the rate of change of distance
# Calculate positions slightly apart to get velocity
dt_seconds = 1.0
t_future = self._ts.utc(t.utc_datetime() + timedelta(seconds=dt_seconds))
topocentric_future = difference.at(t_future)
_, _, distance_future = topocentric_future.altaz()
# Range rate in km/s (negative = approaching = positive Doppler)
range_rate_km_s = (distance_future.km - distance.km) / dt_seconds
# Calculate Doppler shift
# f_received = f_transmitted * (1 - v_radial / c)
# When approaching (negative range_rate), frequency is higher
nominal_freq_hz = nominal_freq_mhz * 1_000_000
doppler_factor = 1 - (range_rate_km_s * 1000 / SPEED_OF_LIGHT)
corrected_freq_hz = nominal_freq_hz * doppler_factor
shift_hz = corrected_freq_hz - nominal_freq_hz
return DopplerInfo(
frequency_hz=corrected_freq_hz,
shift_hz=shift_hz,
range_rate_km_s=range_rate_km_s,
elevation=alt.degrees,
azimuth=az.degrees,
timestamp=datetime.now(timezone.utc)
)
except Exception as e:
logger.error(f"Doppler calculation failed: {e}")
return None
@dataclass @dataclass
class SSTVImage: class SSTVImage:
"""Decoded SSTV image.""" """Decoded SSTV image."""
@@ -76,19 +219,34 @@ class DecodeProgress:
class SSTVDecoder: class SSTVDecoder:
"""SSTV decoder using external tools (slowrx or qsstv).""" """SSTV decoder using external tools (slowrx) with Doppler compensation."""
# Minimum frequency change (Hz) before retuning rtl_fm
RETUNE_THRESHOLD_HZ = 500
# How often to check/update Doppler (seconds)
DOPPLER_UPDATE_INTERVAL = 5
def __init__(self, output_dir: str | Path | None = None): def __init__(self, output_dir: str | Path | None = None):
self._process = None self._process = None
self._rtl_process = None
self._running = False self._running = False
self._lock = threading.Lock() self._lock = threading.Lock()
self._callback: Callable[[DecodeProgress], None] | None = None self._callback: Callable[[DecodeProgress], None] | None = None
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images') self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
self._images: list[SSTVImage] = [] self._images: list[SSTVImage] = []
self._reader_thread = None self._reader_thread = None
self._watcher_thread = None
self._doppler_thread = None
self._frequency = ISS_SSTV_FREQ self._frequency = ISS_SSTV_FREQ
self._current_tuned_freq_hz: int = 0
self._device_index = 0 self._device_index = 0
# Doppler tracking
self._doppler_tracker = DopplerTracker('ISS')
self._doppler_enabled = False
self._last_doppler_info: DopplerInfo | None = None
# Ensure output directory exists # Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True) self._output_dir.mkdir(parents=True, exist_ok=True)
@@ -114,13 +272,7 @@ class SSTVDecoder:
except Exception: except Exception:
pass pass
# Check for qsstv (if available as CLI) # Note: qsstv is GUI-only and not suitable for headless/server operation
try:
result = subprocess.run(['which', 'qsstv'], capture_output=True, timeout=5)
if result.returncode == 0:
return 'qsstv'
except Exception:
pass
# Check for Python sstv package # Check for Python sstv package
try: try:
@@ -129,20 +281,28 @@ class SSTVDecoder:
except ImportError: except ImportError:
pass pass
logger.warning("No SSTV decoder found. Install slowrx or python sstv package.") logger.warning("No SSTV decoder found. Install slowrx (apt install slowrx) or python sstv package. Note: qsstv is GUI-only and not supported for headless operation.")
return None return None
def set_callback(self, callback: Callable[[DecodeProgress], None]) -> None: def set_callback(self, callback: Callable[[DecodeProgress], None]) -> None:
"""Set callback for decode progress updates.""" """Set callback for decode progress updates."""
self._callback = callback self._callback = callback
def start(self, frequency: float = ISS_SSTV_FREQ, device_index: int = 0) -> bool: def start(
self,
frequency: float = ISS_SSTV_FREQ,
device_index: int = 0,
latitude: float | None = None,
longitude: float | None = None,
) -> bool:
""" """
Start SSTV decoder listening on specified frequency. Start SSTV decoder listening on specified frequency.
Args: Args:
frequency: Frequency in MHz (default: 145.800 for ISS) frequency: Frequency in MHz (default: 145.800 for ISS)
device_index: RTL-SDR device index device_index: RTL-SDR device index
latitude: Observer latitude for Doppler correction (optional)
longitude: Observer longitude for Doppler correction (optional)
Returns: Returns:
True if started successfully True if started successfully
@@ -162,6 +322,15 @@ class SSTVDecoder:
self._frequency = frequency self._frequency = frequency
self._device_index = device_index self._device_index = device_index
# Configure Doppler tracking if location provided
self._doppler_enabled = False
if latitude is not None and longitude is not None:
if self._doppler_tracker.configure(latitude, longitude):
self._doppler_enabled = True
logger.info(f"Doppler tracking enabled for location ({latitude}, {longitude})")
else:
logger.warning("Doppler tracking unavailable - using fixed frequency")
try: try:
if self._decoder == 'slowrx': if self._decoder == 'slowrx':
self._start_slowrx() self._start_slowrx()
@@ -172,11 +341,23 @@ class SSTVDecoder:
return False return False
self._running = True self._running = True
logger.info(f"SSTV decoder started on {frequency} MHz")
self._emit_progress(DecodeProgress( # Start Doppler tracking thread if enabled
status='detecting', if self._doppler_enabled:
message=f'Listening on {frequency} MHz...' self._doppler_thread = threading.Thread(target=self._doppler_tracking_loop, daemon=True)
)) self._doppler_thread.start()
logger.info(f"SSTV decoder started on {frequency} MHz with Doppler tracking")
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Listening on {frequency} MHz with Doppler tracking...'
))
else:
logger.info(f"SSTV decoder started on {frequency} MHz (no Doppler tracking)")
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Listening on {frequency} MHz...'
))
return True return True
except Exception as e: except Exception as e:
@@ -189,9 +370,32 @@ class SSTVDecoder:
def _start_slowrx(self) -> None: def _start_slowrx(self) -> None:
"""Start slowrx decoder with rtl_fm piped input.""" """Start slowrx decoder with rtl_fm piped input."""
# Convert frequency to Hz # Calculate initial frequency (with Doppler correction if enabled)
freq_hz = int(self._frequency * 1_000_000) freq_hz = self._get_doppler_corrected_freq_hz()
self._current_tuned_freq_hz = freq_hz
self._start_rtl_fm_pipeline(freq_hz)
def _get_doppler_corrected_freq_hz(self) -> int:
"""Get the Doppler-corrected frequency in Hz."""
nominal_freq_hz = int(self._frequency * 1_000_000)
if self._doppler_enabled:
doppler_info = self._doppler_tracker.calculate(self._frequency)
if doppler_info:
self._last_doppler_info = doppler_info
corrected_hz = int(doppler_info.frequency_hz)
logger.info(
f"Doppler correction: {doppler_info.shift_hz:+.1f} Hz "
f"(range rate: {doppler_info.range_rate_km_s:+.3f} km/s, "
f"el: {doppler_info.elevation:.1f}°)"
)
return corrected_hz
return nominal_freq_hz
def _start_rtl_fm_pipeline(self, freq_hz: int) -> None:
"""Start the rtl_fm -> slowrx pipeline at the specified frequency."""
# Build rtl_fm command for FM demodulation # Build rtl_fm command for FM demodulation
rtl_cmd = [ rtl_cmd = [
'rtl_fm', 'rtl_fm',
@@ -237,6 +441,106 @@ class SSTVDecoder:
self._watcher_thread = threading.Thread(target=self._watch_images, daemon=True) self._watcher_thread = threading.Thread(target=self._watch_images, daemon=True)
self._watcher_thread.start() self._watcher_thread.start()
def _doppler_tracking_loop(self) -> None:
"""Background thread that monitors Doppler shift and retunes when needed."""
logger.info("Doppler tracking thread started")
while self._running and self._doppler_enabled:
time.sleep(self.DOPPLER_UPDATE_INTERVAL)
if not self._running:
break
try:
doppler_info = self._doppler_tracker.calculate(self._frequency)
if not doppler_info:
continue
self._last_doppler_info = doppler_info
new_freq_hz = int(doppler_info.frequency_hz)
freq_diff = abs(new_freq_hz - self._current_tuned_freq_hz)
# Log current Doppler status
logger.debug(
f"Doppler: {doppler_info.shift_hz:+.1f} Hz, "
f"el: {doppler_info.elevation:.1f}°, "
f"diff from tuned: {freq_diff} Hz"
)
# Emit Doppler update to callback
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Doppler: {doppler_info.shift_hz:+.0f} Hz, elevation: {doppler_info.elevation:.1f}°'
))
# Retune if frequency has drifted enough
if freq_diff >= self.RETUNE_THRESHOLD_HZ:
logger.info(
f"Retuning: {self._current_tuned_freq_hz} -> {new_freq_hz} Hz "
f"(Doppler shift: {doppler_info.shift_hz:+.1f} Hz)"
)
self._retune_rtl_fm(new_freq_hz)
except Exception as e:
logger.error(f"Doppler tracking error: {e}")
logger.info("Doppler tracking thread stopped")
def _retune_rtl_fm(self, new_freq_hz: int) -> None:
"""
Retune rtl_fm to a new frequency.
Since rtl_fm doesn't support dynamic frequency changes, we need to
restart the rtl_fm process. The slowrx process continues running
and will resume decoding when audio resumes.
"""
with self._lock:
if not self._running:
return
# Terminate old rtl_fm process
if self._rtl_process:
try:
self._rtl_process.terminate()
self._rtl_process.wait(timeout=2)
except Exception:
try:
self._rtl_process.kill()
except Exception:
pass
# Start new rtl_fm at new frequency
rtl_cmd = [
'rtl_fm',
'-d', str(self._device_index),
'-f', str(new_freq_hz),
'-M', 'fm',
'-s', '48000',
'-r', '48000',
'-l', '0',
'-'
]
logger.debug(f"Restarting rtl_fm: {' '.join(rtl_cmd)}")
self._rtl_process = subprocess.Popen(
rtl_cmd,
stdout=self._process.stdin if self._process else subprocess.PIPE,
stderr=subprocess.PIPE
)
self._current_tuned_freq_hz = new_freq_hz
@property
def last_doppler_info(self) -> DopplerInfo | None:
"""Get the most recent Doppler calculation."""
return self._last_doppler_info
@property
def doppler_enabled(self) -> bool:
"""Check if Doppler tracking is enabled."""
return self._doppler_enabled
def _start_python_sstv(self) -> None: def _start_python_sstv(self) -> None:
"""Start Python SSTV decoder (requires audio file input).""" """Start Python SSTV decoder (requires audio file input)."""
# Python sstv package typically works with audio files # Python sstv package typically works with audio files
+616
View File
@@ -0,0 +1,616 @@
"""
Deauthentication attack detector using scapy.
Monitors a WiFi interface in monitor mode for deauthentication and disassociation
frames, detecting potential deauth flood attacks.
"""
from __future__ import annotations
import logging
import threading
import time
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable, Optional, Any
from utils.constants import (
DEAUTH_DETECTION_WINDOW,
DEAUTH_ALERT_THRESHOLD,
DEAUTH_CRITICAL_THRESHOLD,
DEAUTH_SNIFF_TIMEOUT,
)
logger = logging.getLogger(__name__)
# Deauth reason code descriptions
DEAUTH_REASON_CODES = {
0: "Reserved",
1: "Unspecified reason",
2: "Previous authentication no longer valid",
3: "Station is leaving (or has left) IBSS or ESS",
4: "Disassociated due to inactivity",
5: "Disassociated because AP is unable to handle all currently associated STAs",
6: "Class 2 frame received from nonauthenticated STA",
7: "Class 3 frame received from nonassociated STA",
8: "Disassociated because sending STA is leaving (or has left) BSS",
9: "STA requesting (re)association is not authenticated with responding STA",
10: "Disassociated because the information in the Power Capability element is unacceptable",
11: "Disassociated because the information in the Supported Channels element is unacceptable",
12: "Disassociated due to BSS Transition Management",
13: "Invalid information element",
14: "MIC failure",
15: "4-Way Handshake timeout",
16: "Group Key Handshake timeout",
17: "Information element in 4-Way Handshake different from (Re)Association Request/Probe Response/Beacon frame",
18: "Invalid group cipher",
19: "Invalid pairwise cipher",
20: "Invalid AKMP",
21: "Unsupported RSNE version",
22: "Invalid RSNE capabilities",
23: "IEEE 802.1X authentication failed",
24: "Cipher suite rejected because of security policy",
}
@dataclass
class DeauthPacketInfo:
"""Information about a captured deauth/disassoc packet."""
timestamp: float
frame_type: str # 'deauth' or 'disassoc'
src_mac: str
dst_mac: str
bssid: str
reason_code: int
signal_dbm: Optional[int] = None
@dataclass
class DeauthTracker:
"""Tracks deauth packets for a specific source/dest/bssid combination."""
packets: list[DeauthPacketInfo] = field(default_factory=list)
first_seen: float = 0.0
last_seen: float = 0.0
alert_sent: bool = False
def add_packet(self, pkt: DeauthPacketInfo):
self.packets.append(pkt)
now = pkt.timestamp
if self.first_seen == 0.0:
self.first_seen = now
self.last_seen = now
def get_packets_in_window(self, window_seconds: float) -> list[DeauthPacketInfo]:
"""Get packets within the time window."""
cutoff = time.time() - window_seconds
return [p for p in self.packets if p.timestamp >= cutoff]
def cleanup_old_packets(self, window_seconds: float):
"""Remove packets older than the window."""
cutoff = time.time() - window_seconds
self.packets = [p for p in self.packets if p.timestamp >= cutoff]
if self.packets:
self.first_seen = self.packets[0].timestamp
else:
self.first_seen = 0.0
self.alert_sent = False
@dataclass
class DeauthAlert:
"""A deauthentication attack alert."""
id: str
timestamp: float
severity: str # 'low', 'medium', 'high'
# Attacker info
attacker_mac: str
attacker_vendor: Optional[str]
attacker_signal_dbm: Optional[int]
is_spoofed_ap: bool
# Target info
target_mac: str
target_vendor: Optional[str]
target_type: str # 'client', 'broadcast', 'ap'
target_known_from_scan: bool
# Access point info
ap_bssid: str
ap_essid: Optional[str]
ap_channel: Optional[int]
# Attack info
frame_type: str
reason_code: int
reason_text: str
packet_count: int
window_seconds: float
packets_per_second: float
# Analysis
attack_type: str # 'targeted', 'broadcast', 'ap_flood'
description: str
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'id': self.id,
'type': 'deauth_alert',
'timestamp': self.timestamp,
'severity': self.severity,
'attacker': {
'mac': self.attacker_mac,
'vendor': self.attacker_vendor,
'signal_dbm': self.attacker_signal_dbm,
'is_spoofed_ap': self.is_spoofed_ap,
},
'target': {
'mac': self.target_mac,
'vendor': self.target_vendor,
'type': self.target_type,
'known_from_scan': self.target_known_from_scan,
},
'access_point': {
'bssid': self.ap_bssid,
'essid': self.ap_essid,
'channel': self.ap_channel,
},
'attack_info': {
'frame_type': self.frame_type,
'reason_code': self.reason_code,
'reason_text': self.reason_text,
'packet_count': self.packet_count,
'window_seconds': self.window_seconds,
'packets_per_second': self.packets_per_second,
},
'analysis': {
'attack_type': self.attack_type,
'description': self.description,
},
}
class DeauthDetector:
"""
Detects deauthentication attacks using scapy.
Monitors a WiFi interface in monitor mode for deauth/disassoc frames
and emits alerts when attack thresholds are exceeded.
"""
def __init__(
self,
interface: str,
event_callback: Callable[[dict], None],
get_networks: Optional[Callable[[], dict[str, Any]]] = None,
get_clients: Optional[Callable[[], dict[str, Any]]] = None,
):
"""
Initialize the deauth detector.
Args:
interface: Monitor mode interface to sniff on
event_callback: Callback function to receive alert events
get_networks: Optional function to get current WiFi networks (bssid -> network_info)
get_clients: Optional function to get current WiFi clients (mac -> client_info)
"""
self.interface = interface
self.event_callback = event_callback
self.get_networks = get_networks
self.get_clients = get_clients
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
# Track deauth packets by (src, dst, bssid) tuple
self._trackers: dict[tuple[str, str, str], DeauthTracker] = defaultdict(DeauthTracker)
# Alert history
self._alerts: list[DeauthAlert] = []
self._alert_counter = 0
# Stats
self._packets_captured = 0
self._alerts_generated = 0
self._started_at: Optional[float] = None
@property
def is_running(self) -> bool:
"""Check if detector is running."""
return self._thread is not None and self._thread.is_alive()
@property
def stats(self) -> dict:
"""Get detector statistics."""
return {
'is_running': self.is_running,
'interface': self.interface,
'started_at': self._started_at,
'packets_captured': self._packets_captured,
'alerts_generated': self._alerts_generated,
'active_trackers': len(self._trackers),
}
def start(self) -> bool:
"""
Start detection in background thread.
Returns:
True if started successfully.
"""
if self.is_running:
logger.warning("Deauth detector already running")
return True
self._stop_event.clear()
self._started_at = time.time()
self._thread = threading.Thread(
target=self._sniff_loop,
name="DeauthDetector",
daemon=True,
)
self._thread.start()
logger.info(f"Deauth detector started on {self.interface}")
return True
def stop(self) -> bool:
"""
Stop detection.
Returns:
True if stopped successfully.
"""
if not self.is_running:
return True
logger.info("Stopping deauth detector...")
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning("Deauth detector thread did not stop cleanly")
self._thread = None
self._started_at = None
logger.info("Deauth detector stopped")
return True
def get_alerts(self, limit: int = 100) -> list[dict]:
"""Get recent alerts."""
with self._lock:
return [a.to_dict() for a in self._alerts[-limit:]]
def clear_alerts(self):
"""Clear alert history."""
with self._lock:
self._alerts.clear()
self._trackers.clear()
self._alert_counter = 0
def _sniff_loop(self):
"""Main sniffing loop using scapy."""
try:
from scapy.all import sniff, Dot11, Dot11Deauth, Dot11Disas
except ImportError:
logger.error("scapy not installed. Install with: pip install scapy")
self.event_callback({
'type': 'deauth_error',
'error': 'scapy not installed',
})
return
logger.info(f"Starting deauth sniff on {self.interface}")
def packet_handler(pkt):
"""Handle each captured packet."""
if self._stop_event.is_set():
return
# Check for deauth or disassoc frames
if pkt.haslayer(Dot11Deauth) or pkt.haslayer(Dot11Disas):
self._process_deauth_packet(pkt)
try:
# Use stop_filter to allow clean shutdown
sniff(
iface=self.interface,
prn=packet_handler,
store=False,
stop_filter=lambda _: self._stop_event.is_set(),
timeout=DEAUTH_SNIFF_TIMEOUT,
)
# Continue sniffing until stop is requested
while not self._stop_event.is_set():
sniff(
iface=self.interface,
prn=packet_handler,
store=False,
stop_filter=lambda _: self._stop_event.is_set(),
timeout=DEAUTH_SNIFF_TIMEOUT,
)
# Periodic cleanup
self._cleanup_old_trackers()
except OSError as e:
if "No such device" in str(e):
logger.error(f"Interface {self.interface} not found")
self.event_callback({
'type': 'deauth_error',
'error': f'Interface {self.interface} not found',
})
else:
logger.exception(f"Sniff error: {e}")
self.event_callback({
'type': 'deauth_error',
'error': str(e),
})
except Exception as e:
logger.exception(f"Sniff error: {e}")
self.event_callback({
'type': 'deauth_error',
'error': str(e),
})
def _process_deauth_packet(self, pkt):
"""Process a deauth/disassoc packet and emit alert if threshold exceeded."""
try:
from scapy.all import Dot11, Dot11Deauth, Dot11Disas, RadioTap
except ImportError:
return
# Determine frame type
if pkt.haslayer(Dot11Deauth):
frame_type = 'deauth'
reason_code = pkt[Dot11Deauth].reason
elif pkt.haslayer(Dot11Disas):
frame_type = 'disassoc'
reason_code = pkt[Dot11Disas].reason
else:
return
# Extract addresses from Dot11 layer
dot11 = pkt[Dot11]
dst_mac = (dot11.addr1 or '').upper()
src_mac = (dot11.addr2 or '').upper()
bssid = (dot11.addr3 or '').upper()
# Skip if addresses are missing
if not src_mac or not dst_mac:
return
# Extract signal strength from RadioTap if available
signal_dbm = None
if pkt.haslayer(RadioTap):
try:
signal_dbm = pkt[RadioTap].dBm_AntSignal
except AttributeError:
pass
# Create packet info
pkt_info = DeauthPacketInfo(
timestamp=time.time(),
frame_type=frame_type,
src_mac=src_mac,
dst_mac=dst_mac,
bssid=bssid,
reason_code=reason_code,
signal_dbm=signal_dbm,
)
self._packets_captured += 1
# Track packet
tracker_key = (src_mac, dst_mac, bssid)
with self._lock:
tracker = self._trackers[tracker_key]
tracker.add_packet(pkt_info)
# Check if threshold exceeded
packets_in_window = tracker.get_packets_in_window(DEAUTH_DETECTION_WINDOW)
packet_count = len(packets_in_window)
if packet_count >= DEAUTH_ALERT_THRESHOLD and not tracker.alert_sent:
# Generate alert
alert = self._generate_alert(
tracker_key=tracker_key,
packets=packets_in_window,
packet_count=packet_count,
)
self._alerts.append(alert)
self._alerts_generated += 1
tracker.alert_sent = True
# Emit event
self.event_callback(alert.to_dict())
logger.warning(
f"Deauth attack detected: {src_mac} -> {dst_mac} "
f"({packet_count} packets in {DEAUTH_DETECTION_WINDOW}s)"
)
def _generate_alert(
self,
tracker_key: tuple[str, str, str],
packets: list[DeauthPacketInfo],
packet_count: int,
) -> DeauthAlert:
"""Generate an alert from tracked packets."""
src_mac, dst_mac, bssid = tracker_key
# Get latest packet for details
latest_pkt = packets[-1] if packets else None
# Determine severity
if packet_count >= DEAUTH_CRITICAL_THRESHOLD:
severity = 'high'
elif packet_count >= DEAUTH_ALERT_THRESHOLD * 2.5:
severity = 'medium'
else:
severity = 'low'
# Lookup AP info
ap_info = self._lookup_ap(bssid)
# Lookup target info
target_info = self._lookup_device(dst_mac)
# Determine target type
if dst_mac == 'FF:FF:FF:FF:FF:FF':
target_type = 'broadcast'
elif dst_mac in self._get_known_aps():
target_type = 'ap'
else:
target_type = 'client'
# Check if source is spoofed (matches known AP)
is_spoofed = self._check_spoofed_source(src_mac)
# Get attacker vendor
attacker_vendor = self._get_vendor(src_mac)
# Calculate packets per second
if packets:
time_span = packets[-1].timestamp - packets[0].timestamp
pps = packet_count / time_span if time_span > 0 else float(packet_count)
else:
pps = 0.0
# Determine attack type and description
if dst_mac == 'FF:FF:FF:FF:FF:FF':
attack_type = 'broadcast'
description = "Broadcast deauth flood targeting all clients on the network"
elif target_type == 'ap':
attack_type = 'ap_flood'
description = "Deauth flood targeting access point"
else:
attack_type = 'targeted'
description = f"Targeted deauth flood against {'known' if target_info.get('known_from_scan') else 'unknown'} client"
# Get reason code info
reason_code = latest_pkt.reason_code if latest_pkt else 0
reason_text = DEAUTH_REASON_CODES.get(reason_code, f"Unknown ({reason_code})")
# Get signal
signal_dbm = None
for pkt in reversed(packets):
if pkt.signal_dbm is not None:
signal_dbm = pkt.signal_dbm
break
# Generate unique ID
self._alert_counter += 1
alert_id = f"deauth-{int(time.time())}-{self._alert_counter}"
return DeauthAlert(
id=alert_id,
timestamp=time.time(),
severity=severity,
attacker_mac=src_mac,
attacker_vendor=attacker_vendor,
attacker_signal_dbm=signal_dbm,
is_spoofed_ap=is_spoofed,
target_mac=dst_mac,
target_vendor=target_info.get('vendor'),
target_type=target_type,
target_known_from_scan=target_info.get('known_from_scan', False),
ap_bssid=bssid,
ap_essid=ap_info.get('essid'),
ap_channel=ap_info.get('channel'),
frame_type=latest_pkt.frame_type if latest_pkt else 'deauth',
reason_code=reason_code,
reason_text=reason_text,
packet_count=packet_count,
window_seconds=DEAUTH_DETECTION_WINDOW,
packets_per_second=round(pps, 1),
attack_type=attack_type,
description=description,
)
def _lookup_ap(self, bssid: str) -> dict:
"""Get AP info from current scan data."""
if not self.get_networks:
return {'bssid': bssid, 'essid': None, 'channel': None}
try:
networks = self.get_networks()
ap = networks.get(bssid.upper())
if ap:
return {
'bssid': bssid,
'essid': ap.get('essid') or ap.get('ssid'),
'channel': ap.get('channel'),
}
except Exception as e:
logger.debug(f"Error looking up AP {bssid}: {e}")
return {'bssid': bssid, 'essid': None, 'channel': None}
def _lookup_device(self, mac: str) -> dict:
"""Get device info and vendor from MAC."""
vendor = self._get_vendor(mac)
known_from_scan = False
if self.get_clients:
try:
clients = self.get_clients()
if mac.upper() in clients:
known_from_scan = True
except Exception:
pass
return {
'mac': mac,
'vendor': vendor,
'known_from_scan': known_from_scan,
}
def _get_known_aps(self) -> set[str]:
"""Get set of known AP BSSIDs."""
if not self.get_networks:
return set()
try:
networks = self.get_networks()
return {bssid.upper() for bssid in networks.keys()}
except Exception:
return set()
def _check_spoofed_source(self, src_mac: str) -> bool:
"""Check if source MAC matches a known AP (spoofing indicator)."""
return src_mac.upper() in self._get_known_aps()
def _get_vendor(self, mac: str) -> Optional[str]:
"""Get vendor from MAC OUI."""
try:
from data.oui import get_manufacturer
vendor = get_manufacturer(mac)
return vendor if vendor != 'Unknown' else None
except Exception:
pass
# Fallback to wifi constants
try:
from utils.wifi.constants import get_vendor_from_mac
return get_vendor_from_mac(mac)
except Exception:
return None
def _cleanup_old_trackers(self):
"""Remove old packets and empty trackers."""
with self._lock:
keys_to_remove = []
for key, tracker in self._trackers.items():
tracker.cleanup_old_packets(DEAUTH_DETECTION_WINDOW * 2)
if not tracker.packets:
keys_to_remove.append(key)
for key in keys_to_remove:
del self._trackers[key]
+114 -1
View File
@@ -19,7 +19,10 @@ import threading
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Callable, Generator, Optional from typing import Callable, Generator, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .deauth_detector import DeauthDetector
from .constants import ( from .constants import (
DEFAULT_QUICK_SCAN_TIMEOUT, DEFAULT_QUICK_SCAN_TIMEOUT,
@@ -87,6 +90,9 @@ class UnifiedWiFiScanner:
self._deep_scan_thread: Optional[threading.Thread] = None self._deep_scan_thread: Optional[threading.Thread] = None
self._deep_scan_stop_event = threading.Event() self._deep_scan_stop_event = threading.Event()
# Deauth detector
self._deauth_detector: Optional['DeauthDetector'] = None
# Event queue for SSE streaming # Event queue for SSE streaming
self._event_queue: queue.Queue = queue.Queue(maxsize=1000) self._event_queue: queue.Queue = queue.Queue(maxsize=1000)
@@ -623,6 +629,9 @@ class UnifiedWiFiScanner:
'interface': iface, 'interface': iface,
}) })
# Auto-start deauth detector
self._start_deauth_detector(iface)
return True return True
def stop_deep_scan(self) -> bool: def stop_deep_scan(self) -> bool:
@@ -636,6 +645,9 @@ class UnifiedWiFiScanner:
if not self._status.is_scanning: if not self._status.is_scanning:
return True return True
# Stop deauth detector first
self._stop_deauth_detector()
self._deep_scan_stop_event.set() self._deep_scan_stop_event.set()
if self._deep_scan_process: if self._deep_scan_process:
@@ -1148,6 +1160,107 @@ class UnifiedWiFiScanner:
with self._lock: with self._lock:
return [ap.to_legacy_dict() for ap in self._access_points.values()] return [ap.to_legacy_dict() for ap in self._access_points.values()]
# =========================================================================
# Deauth Detection Integration
# =========================================================================
def _start_deauth_detector(self, interface: str):
"""Start deauth detector on the given interface."""
try:
from .deauth_detector import DeauthDetector
except ImportError as e:
logger.warning(f"Could not import DeauthDetector (scapy not installed?): {e}")
return
if self._deauth_detector and self._deauth_detector.is_running:
logger.debug("Deauth detector already running")
return
def event_callback(event: dict):
"""Handle deauth events and forward to queue."""
self._queue_event(event)
# Also store in app-level DataStore if available
try:
import app as app_module
if hasattr(app_module, 'deauth_alerts') and event.get('type') == 'deauth_alert':
alert_id = event.get('id', str(time.time()))
app_module.deauth_alerts[alert_id] = event
if hasattr(app_module, 'deauth_detector_queue'):
try:
app_module.deauth_detector_queue.put_nowait(event)
except queue.Full:
pass
except Exception as e:
logger.debug(f"Error storing deauth alert: {e}")
def get_networks() -> dict:
"""Get current networks for cross-reference."""
with self._lock:
return {bssid: ap.to_summary_dict() for bssid, ap in self._access_points.items()}
def get_clients() -> dict:
"""Get current clients for cross-reference."""
with self._lock:
return {mac: client.to_dict() for mac, client in self._clients.items()}
try:
self._deauth_detector = DeauthDetector(
interface=interface,
event_callback=event_callback,
get_networks=get_networks,
get_clients=get_clients,
)
self._deauth_detector.start()
logger.info(f"Deauth detector started on {interface}")
self._queue_event({
'type': 'deauth_detector_started',
'interface': interface,
})
except Exception as e:
logger.error(f"Failed to start deauth detector: {e}")
self._queue_event({
'type': 'deauth_error',
'error': f"Failed to start deauth detector: {e}",
})
def _stop_deauth_detector(self):
"""Stop the deauth detector."""
if self._deauth_detector:
try:
self._deauth_detector.stop()
logger.info("Deauth detector stopped")
self._queue_event({
'type': 'deauth_detector_stopped',
})
except Exception as e:
logger.error(f"Error stopping deauth detector: {e}")
finally:
self._deauth_detector = None
@property
def deauth_detector(self) -> Optional['DeauthDetector']:
"""Get the deauth detector instance."""
return self._deauth_detector
def get_deauth_alerts(self, limit: int = 100) -> list[dict]:
"""Get recent deauth alerts."""
if self._deauth_detector:
return self._deauth_detector.get_alerts(limit)
return []
def clear_deauth_alerts(self):
"""Clear deauth alert history."""
if self._deauth_detector:
self._deauth_detector.clear_alerts()
# Also clear from app-level store
try:
import app as app_module
if hasattr(app_module, 'deauth_alerts'):
app_module.deauth_alerts.clear()
except Exception:
pass
# ============================================================================= # =============================================================================
# Module-level functions # Module-level functions