mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 08:13:32 -07:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f795180c7d | |||
| d1f1ce1f4b | |||
| 334073089f | |||
| df634dc741 | |||
| a76dfde02d | |||
| 36f8349bc7 | |||
| 130a3a2d8e | |||
| bd6fa27970 | |||
| 630bc2971a | |||
| 7182f7803a | |||
| a64a7c414c | |||
| f0cc396a6b | |||
| 5f588a5513 | |||
| 599df7734b | |||
| 49fa02142d | |||
| 333dc00ee2 | |||
| 2bc71e44ad | |||
| 92265da5fb | |||
| 9c1516c086 | |||
| cd7940bdc2 | |||
| 4a5f3e1802 | |||
| 1b5bf4c061 | |||
| 384d02649a | |||
| d51da40a67 |
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user