diff --git a/Dockerfile b/Dockerfile
index 1c88b70..4a22cd6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,24 +3,47 @@
FROM python:3.11-slim
+LABEL maintainer="INTERCEPT Project"
+LABEL description="Signal Intelligence Platform for SDR monitoring"
+
# Set working directory
WORKDIR /app
-# Install system dependencies for RTL-SDR tools
+# Install system dependencies for SDR tools
RUN apt-get update && apt-get install -y --no-install-recommends \
# RTL-SDR tools
rtl-sdr \
+ librtlsdr-dev \
+ libusb-1.0-0-dev \
# 433MHz decoder
rtl-433 \
# Pager decoder
multimon-ng \
+ # Audio tools for Listening Post
+ sox \
+ libsox-fmt-all \
# WiFi tools (aircrack-ng suite)
aircrack-ng \
+ iw \
+ wireless-tools \
# Bluetooth tools
bluez \
- # Cleanup
+ bluetooth \
+ # GPS support
+ gpsd-clients \
+ # Utilities
+ curl \
+ procps \
&& rm -rf /var/lib/apt/lists/*
+# Install dump1090 for ADS-B (package name varies by distribution)
+RUN apt-get update && \
+ (apt-get install -y --no-install-recommends dump1090-mutability || \
+ apt-get install -y --no-install-recommends dump1090-fa || \
+ apt-get install -y --no-install-recommends dump1090 || \
+ echo "Note: dump1090 not available in repos, ADS-B features limited") && \
+ rm -rf /var/lib/apt/lists/*
+
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
@@ -28,13 +51,21 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
+# Create data directory for persistence
+RUN mkdir -p /app/data
+
# Expose web interface port
EXPOSE 5050
# Environment variables with defaults
ENV INTERCEPT_HOST=0.0.0.0 \
INTERCEPT_PORT=5050 \
- INTERCEPT_LOG_LEVEL=INFO
+ INTERCEPT_LOG_LEVEL=INFO \
+ PYTHONUNBUFFERED=1
+
+# Health check using the new endpoint
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD curl -sf http://localhost:5050/health || exit 1
# Run the application
CMD ["python", "intercept.py"]
diff --git a/README.md b/README.md
index 947a19a..6661cd3 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
Signal Intelligence Platform
- A web-based front-end for signal intelligence tools.
+ A web-based interface for software-defined radio tools.
@@ -17,19 +17,141 @@
---
-## What is INTERCEPT?
-
-INTERCEPT provides a unified web interface for signal intelligence tools:
+## Features
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
-- **433MHz Sensors** - Weather stations, TPMS, IoT via rtl_433
-- **Aircraft Tracking** - ADS-B via dump1090 with real-time map
+- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
+- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
+- **Listening Post** - Frequency scanner with audio monitoring
- **Satellite Tracking** - Pass prediction using TLE data
-- **WiFi Recon** - Monitor mode scanning via aircrack-ng
+- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection
---
+## Installation
+
+### macOS
+
+**1. Install Homebrew** (if not already installed):
+```bash
+/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+```
+
+**2. Install dependencies:**
+```bash
+# Required
+brew install python@3.11 librtlsdr multimon-ng rtl_433 sox
+
+# For ADS-B aircraft tracking
+brew install dump1090-mutability
+
+# For WiFi scanning (optional)
+brew install aircrack-ng
+```
+
+**3. Clone and run:**
+```bash
+git clone https://github.com/smittix/intercept.git
+cd intercept
+./setup.sh
+sudo python3 intercept.py
+```
+
+### Debian / Ubuntu / Raspberry Pi OS
+
+**1. Install dependencies:**
+```bash
+sudo apt update
+sudo apt install -y python3 python3-pip python3-venv git
+
+# Required SDR tools
+sudo apt install -y rtl-sdr multimon-ng rtl-433 sox
+
+# For ADS-B aircraft tracking (package name varies)
+sudo apt install -y dump1090-mutability # or dump1090-fa
+
+# For WiFi scanning (optional)
+sudo apt install -y aircrack-ng
+
+# For Bluetooth scanning (optional)
+sudo apt install -y bluez bluetooth
+```
+
+**2. Clone and run:**
+```bash
+git clone https://github.com/smittix/intercept.git
+cd intercept
+./setup.sh
+sudo python3 intercept.py
+```
+
+> **Note:** On Raspberry Pi or headless systems, you may need to run `sudo venv/bin/python intercept.py` if a virtual environment was created.
+
+### Docker (Alternative)
+
+```bash
+git clone https://github.com/smittix/intercept.git
+cd intercept
+docker-compose up -d
+```
+
+> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
+
+### Open the Interface
+
+After starting, open **http://localhost:5050** in your browser.
+
+---
+
+## Hardware Requirements
+
+| Hardware | Purpose | Price |
+|----------|---------|-------|
+| **RTL-SDR** | Required for all SDR features | ~$25-35 |
+| **WiFi adapter** | Monitor mode scanning (optional) | ~$20-40 |
+| **Bluetooth adapter** | Device scanning (usually built-in) | - |
+
+Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
+
+---
+
+## Troubleshooting
+
+### RTL-SDR not detected (Linux)
+
+Add udev rules:
+```bash
+sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
+SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
+SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
+EOF'
+sudo udevadm control --reload-rules && sudo udevadm trigger
+```
+Then unplug and replug your RTL-SDR.
+
+### "externally-managed-environment" error (Ubuntu 23.04+)
+
+The setup script handles this automatically by creating a virtual environment. Run:
+```bash
+./setup.sh
+source venv/bin/activate
+sudo venv/bin/python intercept.py
+```
+
+### dump1090 not available (Debian Trixie)
+
+On newer Debian versions, dump1090 may not be in repositories. Install from FlightAware:
+- https://flightaware.com/adsb/piaware/install
+
+### Verify installation
+
+```bash
+python3 intercept.py --check-deps
+```
+
+---
+
## Community
@@ -38,75 +160,20 @@ INTERCEPT provides a unified web interface for signal intelligence tools:
---
-## Quick Start
-
-```bash
-git clone https://github.com/smittix/intercept.git
-cd intercept
-./setup.sh
-sudo python3 intercept.py
-```
-
-Open http://localhost:5050 in your browser.
-
-
-Alternative: Install with uv
-
-```bash
-git clone https://github.com/smittix/intercept.git
-cd intercept
-uv venv
-source .venv/bin/activate # On Windows: .venv\Scripts\activate
-uv sync
-sudo python3 intercept.py
-```
-
-
-> **Note:** Requires Python 3.9+ and external tools. See [Hardware & Installation](docs/HARDWARE.md).
-
----
-
-## Requirements
-
-- **Python 3.9+**
-- **SDR Hardware** - RTL-SDR (~$25), LimeSDR, or HackRF
-- **External Tools** - rtl-sdr, multimon-ng, rtl_433, dump1090, aircrack-ng
-
-Quick install (Ubuntu/Debian):
-```bash
-sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez
-```
-
-See [Hardware & Installation](docs/HARDWARE.md) for full details.
-
----
-
## Documentation
-| Document | Description |
-|----------|-------------|
-| [Features](docs/FEATURES.md) | Complete feature list for all modules |
-| [Usage Guide](docs/USAGE.md) | Detailed instructions for each mode |
-| [Troubleshooting](docs/TROUBLESHOOTING.md) | Solutions for common issues |
-| [Hardware & Installation](docs/HARDWARE.md) | SDR hardware and tool installation |
-
----
-
-## Development
-
-This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
-
-Contributions and improvements welcome.
+- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
+- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
+- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
---
## Disclaimer
-**This software is for educational purposes only.**
+**This software is for educational and authorized testing purposes only.**
- Only use with proper authorization
- Intercepting communications without consent may be illegal
-- WiFi/Bluetooth attacks require explicit permission
- You are responsible for compliance with applicable laws
---
@@ -118,13 +185,3 @@ MIT License - see [LICENSE](LICENSE)
## Author
Created by **smittix** - [GitHub](https://github.com/smittix)
-
-## Acknowledgments
-
-[rtl-sdr](https://osmocom.org/projects/rtl-sdr/wiki) |
-[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
-[rtl_433](https://github.com/merbanan/rtl_433) |
-[dump1090](https://github.com/flightaware/dump1090) |
-[aircrack-ng](https://www.aircrack-ng.org/) |
-[Leaflet.js](https://leafletjs.com/) |
-[Celestrak](https://celestrak.org/)
diff --git a/app.py b/app.py
index 4c23541..27fcdc9 100644
--- a/app.py
+++ b/app.py
@@ -29,6 +29,17 @@ from config import VERSION
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes
from utils.sdr import SDRFactory
+from utils.cleanup import DataStore, cleanup_manager
+from utils.constants import (
+ MAX_AIRCRAFT_AGE_SECONDS,
+ MAX_WIFI_NETWORK_AGE_SECONDS,
+ MAX_BT_DEVICE_AGE_SECONDS,
+ QUEUE_MAX_SIZE,
+)
+
+# Track application start time for uptime calculation
+import time as _time
+_app_start_time = _time.time()
# Create Flask app
@@ -40,32 +51,32 @@ app = Flask(__name__)
# Pager decoder
current_process = None
-output_queue = queue.Queue()
+output_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
process_lock = threading.Lock()
# RTL_433 sensor
sensor_process = None
-sensor_queue = queue.Queue()
+sensor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
sensor_lock = threading.Lock()
# WiFi
wifi_process = None
-wifi_queue = queue.Queue()
+wifi_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
wifi_lock = threading.Lock()
# Bluetooth
bt_process = None
-bt_queue = queue.Queue()
+bt_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
bt_lock = threading.Lock()
# ADS-B aircraft
adsb_process = None
-adsb_queue = queue.Queue()
+adsb_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
adsb_lock = threading.Lock()
# Satellite/Iridium
satellite_process = None
-satellite_queue = queue.Queue()
+satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
satellite_lock = threading.Lock()
# ============================================
@@ -76,23 +87,30 @@ satellite_lock = threading.Lock()
logging_enabled = False
log_file_path = 'pager_messages.log'
-# WiFi state
+# WiFi state - using DataStore for automatic cleanup
wifi_monitor_interface = None
-wifi_networks = {} # BSSID -> network info
-wifi_clients = {} # Client MAC -> client info
-wifi_handshakes = [] # Captured handshakes
+wifi_networks = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_networks')
+wifi_clients = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_clients')
+wifi_handshakes = [] # Captured handshakes (list, not auto-cleaned)
-# Bluetooth state
+# Bluetooth state - using DataStore for automatic cleanup
bt_interface = None
-bt_devices = {} # MAC -> device info
-bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons)
-bt_services = {} # MAC -> list of services
+bt_devices = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_devices')
+bt_beacons = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_beacons')
+bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested)
-# Aircraft (ADS-B) state
-adsb_aircraft = {} # ICAO hex -> aircraft info
+# Aircraft (ADS-B) state - using DataStore for automatic cleanup
+adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
# Satellite state
-satellite_passes = [] # Predicted satellite passes
+satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
+
+# Register data stores with cleanup manager
+cleanup_manager.register(wifi_networks)
+cleanup_manager.register(wifi_clients)
+cleanup_manager.register(bt_devices)
+cleanup_manager.register(bt_beacons)
+cleanup_manager.register(adsb_aircraft)
# ============================================
@@ -130,15 +148,16 @@ def get_dependencies() -> Response:
# Determine OS for install instructions
system = platform.system().lower()
if system == 'darwin':
- install_method = 'brew'
+ pkg_manager = 'brew'
elif system == 'linux':
- install_method = 'apt'
+ pkg_manager = 'apt'
else:
- install_method = 'manual'
+ pkg_manager = 'manual'
return jsonify({
+ 'status': 'success',
'os': system,
- 'install_method': install_method,
+ 'pkg_manager': pkg_manager,
'modes': results
})
@@ -159,14 +178,14 @@ def export_aircraft() -> Response:
for icao, ac in adsb_aircraft.items():
writer.writerow([
icao,
- ac.get('callsign', ''),
- ac.get('altitude', ''),
- ac.get('speed', ''),
- ac.get('heading', ''),
- ac.get('lat', ''),
- ac.get('lon', ''),
- ac.get('squawk', ''),
- ac.get('lastSeen', '')
+ ac.get('callsign', '') if isinstance(ac, dict) else '',
+ ac.get('altitude', '') if isinstance(ac, dict) else '',
+ ac.get('speed', '') if isinstance(ac, dict) else '',
+ ac.get('heading', '') if isinstance(ac, dict) else '',
+ ac.get('lat', '') if isinstance(ac, dict) else '',
+ ac.get('lon', '') if isinstance(ac, dict) else '',
+ ac.get('squawk', '') if isinstance(ac, dict) else '',
+ ac.get('lastSeen', '') if isinstance(ac, dict) else ''
])
response = Response(output.getvalue(), mimetype='text/csv')
@@ -175,7 +194,7 @@ def export_aircraft() -> Response:
else:
return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
- 'aircraft': list(adsb_aircraft.values())
+ 'aircraft': adsb_aircraft.values()
})
@@ -195,11 +214,11 @@ def export_wifi() -> Response:
for bssid, net in wifi_networks.items():
writer.writerow([
bssid,
- net.get('ssid', ''),
- net.get('channel', ''),
- net.get('signal', ''),
- net.get('encryption', ''),
- net.get('clients', 0)
+ net.get('ssid', '') if isinstance(net, dict) else '',
+ net.get('channel', '') if isinstance(net, dict) else '',
+ net.get('signal', '') if isinstance(net, dict) else '',
+ net.get('encryption', '') if isinstance(net, dict) else '',
+ net.get('clients', 0) if isinstance(net, dict) else 0
])
response = Response(output.getvalue(), mimetype='text/csv')
@@ -208,8 +227,8 @@ def export_wifi() -> Response:
else:
return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
- 'networks': list(wifi_networks.values()),
- 'clients': list(wifi_clients.values())
+ 'networks': wifi_networks.values(),
+ 'clients': wifi_clients.values()
})
@@ -229,11 +248,11 @@ def export_bluetooth() -> Response:
for mac, dev in bt_devices.items():
writer.writerow([
mac,
- dev.get('name', ''),
- dev.get('rssi', ''),
- dev.get('type', ''),
- dev.get('manufacturer', ''),
- dev.get('lastSeen', '')
+ dev.get('name', '') if isinstance(dev, dict) else '',
+ dev.get('rssi', '') if isinstance(dev, dict) else '',
+ dev.get('type', '') if isinstance(dev, dict) else '',
+ dev.get('manufacturer', '') if isinstance(dev, dict) else '',
+ dev.get('lastSeen', '') if isinstance(dev, dict) else ''
])
response = Response(output.getvalue(), mimetype='text/csv')
@@ -242,11 +261,35 @@ def export_bluetooth() -> Response:
else:
return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
- 'devices': list(bt_devices.values()),
- 'beacons': list(bt_beacons.values())
+ 'devices': bt_devices.values(),
+ 'beacons': bt_beacons.values()
})
+@app.route('/health')
+def health_check() -> Response:
+ """Health check endpoint for monitoring."""
+ import time
+ return jsonify({
+ 'status': 'healthy',
+ 'version': VERSION,
+ 'uptime_seconds': round(time.time() - _app_start_time, 2),
+ 'processes': {
+ 'pager': current_process is not None and (current_process.poll() is None if current_process else False),
+ 'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
+ 'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
+ 'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
+ 'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
+ },
+ 'data': {
+ 'aircraft_count': len(adsb_aircraft),
+ 'wifi_networks_count': len(wifi_networks),
+ 'wifi_clients_count': len(wifi_clients),
+ 'bt_devices_count': len(bt_devices),
+ }
+ })
+
+
@app.route('/killall', methods=['POST'])
def kill_all() -> Response:
"""Kill all decoder and WiFi processes."""
@@ -343,6 +386,13 @@ def main() -> None:
# Clean up any stale processes from previous runs
cleanup_stale_processes()
+ # Initialize database for settings storage
+ from utils.database import init_db
+ init_db()
+
+ # Start automatic cleanup of stale data entries
+ cleanup_manager.start()
+
# Register blueprints
from routes import register_blueprints
register_blueprints(app)
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..674015b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,37 @@
+# INTERCEPT - Signal Intelligence Platform
+# Docker Compose configuration for easy deployment
+
+services:
+ intercept:
+ build: .
+ container_name: intercept
+ ports:
+ - "5050:5050"
+ # Privileged mode required for USB SDR device access
+ # Alternatively, use device mapping (see below)
+ privileged: true
+ # USB device mapping (alternative to privileged mode)
+ # devices:
+ # - /dev/bus/usb:/dev/bus/usb
+ volumes:
+ # Persist data directory
+ - ./data:/app/data
+ # Optional: mount logs directory
+ # - ./logs:/app/logs
+ environment:
+ - INTERCEPT_HOST=0.0.0.0
+ - INTERCEPT_PORT=5050
+ - INTERCEPT_LOG_LEVEL=INFO
+ # Network mode for WiFi scanning (requires host network)
+ # network_mode: host
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 10s
+
+# Optional: Add volume for persistent SQLite database
+# volumes:
+# intercept-data:
diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md
index 2bde523..11eec27 100644
--- a/docs/HARDWARE.md
+++ b/docs/HARDWARE.md
@@ -1,93 +1,75 @@
-# Hardware & Installation
+# Hardware & Advanced Setup
## Supported SDR Hardware
-| Hardware | Frequency Range | Gain Range | TX | Price | Notes |
-|----------|-----------------|------------|-----|-------|-------|
-| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | ~$25 | Most common, budget-friendly |
-| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | ~$300 | Wide range, requires SoapySDR |
-| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | ~$300 | Ultra-wide range, requires SoapySDR |
+| Hardware | Frequency Range | Price | Notes |
+|----------|-----------------|-------|-------|
+| **RTL-SDR** | 24 - 1766 MHz | ~$25-35 | Recommended for beginners |
+| **LimeSDR** | 0.1 - 3800 MHz | ~$300 | Wide range, requires SoapySDR |
+| **HackRF** | 1 - 6000 MHz | ~$300 | Ultra-wide range, requires SoapySDR |
-INTERCEPT automatically detects connected devices and shows hardware-specific capabilities in the UI.
+INTERCEPT automatically detects connected devices.
-## Requirements
+---
-### Hardware
-- **SDR Device** - RTL-SDR, LimeSDR, or HackRF
-- **WiFi adapter** capable of monitor mode (for WiFi features)
-- **Bluetooth adapter** (for Bluetooth features)
-- **GPS dongle** (optional, for precise location)
-
-### Software
-- **Python 3.9+** required
-- External tools (see installation below)
-
-## Tool Installation
-
-### Core SDR Tools
-
-| Tool | macOS | Ubuntu/Debian | Purpose |
-|------|-------|---------------|---------|
-| rtl-sdr | `brew install librtlsdr` | `sudo apt install rtl-sdr` | RTL-SDR support |
-| multimon-ng | `brew install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding |
-| rtl_433 | `brew install rtl_433` | `sudo apt install rtl-433` | 433MHz sensors |
-| dump1090 | `brew install dump1090-mutability` | `sudo apt install dump1090-mutability` | ADS-B aircraft |
-| aircrack-ng | `brew install aircrack-ng` | `sudo apt install aircrack-ng` | WiFi reconnaissance |
-| bluez | Built-in (limited) | `sudo apt install bluez bluetooth` | Bluetooth scanning |
-
-### LimeSDR / HackRF Support (Optional)
-
-| Tool | macOS | Ubuntu/Debian | Purpose |
-|------|-------|---------------|---------|
-| SoapySDR | `brew install soapysdr` | `sudo apt install soapysdr-tools` | Universal SDR abstraction |
-| LimeSDR | `brew install limesuite soapylms7` | `sudo apt install limesuite soapysdr-module-lms7` | LimeSDR support |
-| HackRF | `brew install hackrf soapyhackrf` | `sudo apt install hackrf soapysdr-module-hackrf` | HackRF support |
-| readsb | Build from source | Build from source | ADS-B with SoapySDR |
-
-> **Note:** RTL-SDR works out of the box. LimeSDR and HackRF require SoapySDR plus the hardware-specific driver.
-
-## Quick Install Commands
-
-### Ubuntu/Debian
-> [!NOTE]
-> Known Issue: On the latest version of Debian (Trixie) and those distros that use it dump1090 is not available in the repsitories and will need to be built from source until the developers release it.
-```bash
-# Core tools
-sudo apt update
-sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez bluetooth
-
-# LimeSDR (optional)
-sudo apt install soapysdr-tools limesuite soapysdr-module-lms7
-
-# HackRF (optional)
-sudo apt install hackrf soapysdr-module-hackrf
-```
+## Quick Install
### macOS (Homebrew)
-```bash
-# Core tools
-brew install librtlsdr multimon-ng rtl_433 dump1090-mutability aircrack-ng
-# LimeSDR (optional)
+```bash
+# Install Homebrew if needed
+/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+
+# Core tools (required)
+brew install python@3.11 librtlsdr multimon-ng rtl_433 sox
+
+# ADS-B aircraft tracking
+brew install dump1090-mutability
+
+# WiFi tools (optional)
+brew install aircrack-ng
+
+# LimeSDR support (optional)
brew install soapysdr limesuite soapylms7
-# HackRF (optional)
+# HackRF support (optional)
brew install hackrf soapyhackrf
```
-### Arch Linux
-```bash
-# Core tools
-sudo pacman -S rtl-sdr multimon-ng
-yay -S rtl_433 dump1090
+### Debian / Ubuntu / Raspberry Pi OS
-# LimeSDR/HackRF (optional)
-sudo pacman -S soapysdr limesuite hackrf
+```bash
+# Update package lists
+sudo apt update
+
+# Core tools (required)
+sudo apt install -y python3 python3-pip python3-venv
+sudo apt install -y rtl-sdr multimon-ng rtl-433 sox
+
+# ADS-B aircraft tracking
+sudo apt install -y dump1090-mutability
+# Alternative: dump1090-fa (FlightAware version)
+
+# WiFi tools (optional)
+sudo apt install -y aircrack-ng
+
+# Bluetooth tools (optional)
+sudo apt install -y bluez bluetooth
+
+# LimeSDR support (optional)
+sudo apt install -y soapysdr-tools limesuite soapysdr-module-lms7
+
+# HackRF support (optional)
+sudo apt install -y hackrf soapysdr-module-hackrf
```
-## Linux udev Rules
+---
-If your SDR isn't detected, add udev rules:
+## RTL-SDR Setup (Linux)
+
+### Add udev rules
+
+If your RTL-SDR isn't detected, create udev rules:
```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
@@ -99,9 +81,9 @@ sudo udevadm control --reload-rules
sudo udevadm trigger
```
-Then unplug and replug your device.
+Then unplug and replug your RTL-SDR.
-## Blacklist DVB-T Driver (Linux)
+### Blacklist DVB-T driver
The default DVB-T driver conflicts with rtl-sdr:
@@ -110,57 +92,120 @@ echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
sudo modprobe -r dvb_usb_rtl28xxu
```
+---
+
## Verify Installation
-Check what's installed:
+### Check dependencies
```bash
python3 intercept.py --check-deps
```
-Test SDR detection:
+### Test SDR detection
```bash
# RTL-SDR
rtl_test
-# LimeSDR/HackRF
+# LimeSDR/HackRF (via SoapySDR)
SoapySDRUtil --find
```
-## Python Dependencies
+---
-### Option 1: setup.sh (Recommended)
+## Python Environment
+
+### Using setup.sh (Recommended)
```bash
./setup.sh
```
-This creates a virtual environment and installs dependencies automatically.
-### Option 2: pip
+This automatically:
+- Detects your OS
+- Creates a virtual environment if needed (for PEP 668 systems)
+- Installs Python dependencies
+- Checks for required tools
+
+### Manual setup
```bash
-python3 -m venv .venv
-source .venv/bin/activate
+python3 -m venv venv
+source venv/bin/activate
pip install -r requirements.txt
```
-### Option 3: uv (Fast alternative)
-[uv](https://github.com/astral-sh/uv) is a fast Python package installer.
+---
+
+## Running INTERCEPT
+
+After installation:
```bash
-# Install uv (if not already installed)
-curl -LsSf https://astral.sh/uv/install.sh | sh
+# Standard
+sudo python3 intercept.py
-# Create venv and install deps
-uv venv
-source .venv/bin/activate # On Windows: .venv\Scripts\activate
-uv sync
+# With virtual environment
+sudo venv/bin/python intercept.py
-# Or just install deps in existing environment
-uv pip install -r requirements.txt
+# Custom port
+INTERCEPT_PORT=8080 sudo python3 intercept.py
```
-### Option 4: pip with pyproject.toml
-```bash
-pip install . # Install as package
-pip install -e . # Install in editable mode (for development)
-pip install -e ".[dev]" # Include dev dependencies
-```
+Open **http://localhost:5050** in your browser.
+---
+
+## Complete Tool Reference
+
+| Tool | Package (Debian) | Package (macOS) | Required For |
+|------|------------------|-----------------|--------------|
+| `rtl_fm` | rtl-sdr | librtlsdr | Pager, Listening Post |
+| `rtl_test` | rtl-sdr | librtlsdr | SDR detection |
+| `multimon-ng` | multimon-ng | multimon-ng | Pager decoding |
+| `rtl_433` | rtl-433 | rtl_433 | 433MHz sensors |
+| `dump1090` | dump1090-mutability | dump1090-mutability | ADS-B tracking |
+| `sox` | sox | sox | Listening Post audio |
+| `airmon-ng` | aircrack-ng | aircrack-ng | WiFi monitor mode |
+| `airodump-ng` | aircrack-ng | aircrack-ng | WiFi scanning |
+| `aireplay-ng` | aircrack-ng | aircrack-ng | WiFi deauth (optional) |
+| `hcitool` | bluez | N/A | Bluetooth scanning |
+| `bluetoothctl` | bluez | N/A | Bluetooth control |
+| `hciconfig` | bluez | N/A | Bluetooth config |
+
+### Optional tools:
+| Tool | Package (Debian) | Package (macOS) | Purpose |
+|------|------------------|-----------------|---------|
+| `ffmpeg` | ffmpeg | ffmpeg | Alternative audio encoder |
+| `SoapySDRUtil` | soapysdr-tools | soapysdr | LimeSDR/HackRF support |
+| `LimeUtil` | limesuite | limesuite | LimeSDR native tools |
+| `hackrf_info` | hackrf | hackrf | HackRF native tools |
+
+### Python dependencies (requirements.txt):
+| Package | Purpose |
+|---------|---------|
+| `flask` | Web server |
+| `skyfield` | Satellite tracking (optional) |
+| `pyserial` | USB GPS dongle support (optional) |
+
+---
+
+## dump1090 Notes
+
+### Package names vary by distribution:
+- `dump1090-mutability` - Most common
+- `dump1090-fa` - FlightAware version (recommended)
+- `dump1090` - Generic
+
+### Not in repositories (Debian Trixie)?
+
+Install FlightAware's version:
+https://flightaware.com/adsb/piaware/install
+
+Or build from source:
+https://github.com/flightaware/dump1090
+
+---
+
+## Notes
+
+- **Bluetooth on macOS**: Uses native CoreBluetooth, bluez tools not needed
+- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
+- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
diff --git a/instance/intercept.db b/instance/intercept.db
new file mode 100644
index 0000000..72d2235
Binary files /dev/null and b/instance/intercept.db differ
diff --git a/routes/__init__.py b/routes/__init__.py
index c25ee36..5b6a326 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -9,6 +9,9 @@ def register_blueprints(app):
from .adsb import adsb_bp
from .satellite import satellite_bp
from .gps import gps_bp
+ from .settings import settings_bp
+ from .correlation import correlation_bp
+ from .listening_post import listening_post_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -17,3 +20,6 @@ def register_blueprints(app):
app.register_blueprint(adsb_bp)
app.register_blueprint(satellite_bp)
app.register_blueprint(gps_bp)
+ app.register_blueprint(settings_bp)
+ app.register_blueprint(correlation_bp)
+ app.register_blueprint(listening_post_bp)
diff --git a/routes/adsb.py b/routes/adsb.py
index 81cd1c6..fe0aa45 100644
--- a/routes/adsb.py
+++ b/routes/adsb.py
@@ -22,6 +22,19 @@ from utils.validation import (
)
from utils.sse import format_sse
from utils.sdr import SDRFactory, SDRType
+from utils.constants import (
+ ADSB_SBS_PORT,
+ ADSB_TERMINATE_TIMEOUT,
+ PROCESS_TERMINATE_TIMEOUT,
+ SBS_SOCKET_TIMEOUT,
+ SBS_RECONNECT_DELAY,
+ SOCKET_BUFFER_SIZE,
+ SSE_KEEPALIVE_INTERVAL,
+ SSE_QUEUE_TIMEOUT,
+ SOCKET_CONNECT_TIMEOUT,
+ ADSB_UPDATE_INTERVAL,
+ DUMP1090_START_WAIT,
+)
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
@@ -63,21 +76,21 @@ def find_dump1090():
def check_dump1090_service():
- """Check if dump1090 SBS port (30003) is available."""
+ """Check if dump1090 SBS port is available."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(2)
- result = sock.connect_ex(('localhost', 30003))
+ sock.settimeout(SOCKET_CONNECT_TIMEOUT)
+ result = sock.connect_ex(('localhost', ADSB_SBS_PORT))
sock.close()
if result == 0:
- return 'localhost:30003'
- except Exception:
+ return f'localhost:{ADSB_SBS_PORT}'
+ except OSError:
pass
return None
def parse_sbs_stream(service_addr):
- """Parse SBS format data from dump1090 port 30003."""
+ """Parse SBS format data from dump1090 SBS port."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time
host, port = service_addr.split(':')
@@ -90,7 +103,7 @@ def parse_sbs_stream(service_addr):
while adsb_using_service:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(5)
+ sock.settimeout(SBS_SOCKET_TIMEOUT)
sock.connect((host, port))
adsb_connected = True
logger.info("Connected to SBS stream")
@@ -101,7 +114,7 @@ def parse_sbs_stream(service_addr):
while adsb_using_service:
try:
- data = sock.recv(4096).decode('utf-8', errors='ignore')
+ data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
if not data:
break
buffer += data
@@ -121,7 +134,7 @@ def parse_sbs_stream(service_addr):
if not icao:
continue
- aircraft = app_module.adsb_aircraft.get(icao, {'icao': icao})
+ aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
if msg_type == '1' and len(parts) > 10:
callsign = parts[10].strip()
@@ -168,13 +181,13 @@ def parse_sbs_stream(service_addr):
if parts[17]:
aircraft['squawk'] = parts[17]
- app_module.adsb_aircraft[icao] = aircraft
+ app_module.adsb_aircraft.set(icao, aircraft)
pending_updates.add(icao)
adsb_messages_received += 1
adsb_last_message_time = time.time()
now = time.time()
- if now - last_update >= 1.0:
+ if now - last_update >= ADSB_UPDATE_INTERVAL:
for update_icao in pending_updates:
if update_icao in app_module.adsb_aircraft:
app_module.adsb_queue.put({
@@ -189,10 +202,10 @@ def parse_sbs_stream(service_addr):
sock.close()
adsb_connected = False
- except Exception as e:
+ except OSError as e:
adsb_connected = False
logger.warning(f"SBS connection error: {e}, reconnecting...")
- time.sleep(2)
+ time.sleep(SBS_RECONNECT_DELAY)
adsb_connected = False
logger.info("SBS stream parser stopped")
@@ -291,9 +304,12 @@ def start_adsb():
if app_module.adsb_process:
try:
app_module.adsb_process.terminate()
- app_module.adsb_process.wait(timeout=2)
- except Exception:
- pass
+ app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
+ except (subprocess.TimeoutExpired, OSError):
+ try:
+ app_module.adsb_process.kill()
+ except OSError:
+ pass
app_module.adsb_process = None
# Create device object and build command via abstraction layer
@@ -317,13 +333,13 @@ def start_adsb():
stderr=subprocess.DEVNULL
)
- time.sleep(3)
+ time.sleep(DUMP1090_START_WAIT)
if app_module.adsb_process.poll() is not None:
return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'})
adsb_using_service = True
- thread = threading.Thread(target=parse_sbs_stream, args=('localhost:30003',), daemon=True)
+ thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
thread.start()
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
@@ -340,13 +356,13 @@ def stop_adsb():
if app_module.adsb_process:
app_module.adsb_process.terminate()
try:
- app_module.adsb_process.wait(timeout=5)
+ app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
app_module.adsb_process.kill()
app_module.adsb_process = None
adsb_using_service = False
- app_module.adsb_aircraft = {}
+ app_module.adsb_aircraft.clear()
return jsonify({'status': 'stopped'})
@@ -355,16 +371,15 @@ def stream_adsb():
"""SSE stream for ADS-B aircraft."""
def generate():
last_keepalive = time.time()
- keepalive_interval = 30.0
while True:
try:
- msg = app_module.adsb_queue.get(timeout=1)
+ msg = app_module.adsb_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:
+ if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
diff --git a/routes/bluetooth.py b/routes/bluetooth.py
index aa33b2d..1c8eb21 100644
--- a/routes/bluetooth.py
+++ b/routes/bluetooth.py
@@ -23,6 +23,17 @@ from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
+from utils.constants import (
+ BT_TERMINATE_TIMEOUT,
+ SSE_KEEPALIVE_INTERVAL,
+ SSE_QUEUE_TIMEOUT,
+ SUBPROCESS_TIMEOUT_SHORT,
+ SERVICE_ENUM_TIMEOUT,
+ PROCESS_START_WAIT,
+ BT_RESET_DELAY,
+ BT_ADAPTER_DOWN_WAIT,
+ PROCESS_TERMINATE_TIMEOUT,
+)
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
@@ -113,7 +124,7 @@ def detect_bt_interfaces():
if platform.system() == 'Linux':
try:
- result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
+ result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
for block in blocks:
if block.strip():
@@ -127,8 +138,12 @@ def detect_bt_interfaces():
'type': 'hci',
'status': 'up' if is_up else 'down'
})
- except Exception:
- pass
+ except FileNotFoundError:
+ logger.debug("hciconfig not found")
+ except subprocess.TimeoutExpired:
+ logger.warning("hciconfig timed out")
+ except subprocess.SubprocessError as e:
+ logger.warning(f"Error running hciconfig: {e}")
elif platform.system() == 'Darwin':
interfaces.append({
diff --git a/routes/correlation.py b/routes/correlation.py
new file mode 100644
index 0000000..7869eb0
--- /dev/null
+++ b/routes/correlation.py
@@ -0,0 +1,119 @@
+"""Device correlation routes."""
+
+from __future__ import annotations
+
+from flask import Blueprint, jsonify, request, Response
+
+import app as app_module
+from utils.correlation import get_correlations
+from utils.logging import get_logger
+
+logger = get_logger('intercept.correlation')
+
+correlation_bp = Blueprint('correlation', __name__, url_prefix='/correlation')
+
+
+@correlation_bp.route('', methods=['GET'])
+def get_device_correlations() -> Response:
+ """
+ Get device correlations between WiFi and Bluetooth.
+
+ Query params:
+ min_confidence: Minimum confidence threshold (default 0.5)
+ include_historical: Include database correlations (default true)
+ """
+ min_confidence = request.args.get('min_confidence', 0.5, type=float)
+ include_historical = request.args.get('include_historical', 'true').lower() == 'true'
+
+ try:
+ # Get current device data
+ wifi_devices = dict(app_module.wifi_networks)
+ wifi_devices.update(dict(app_module.wifi_clients))
+ bt_devices = dict(app_module.bt_devices)
+
+ # Calculate correlations
+ correlations = get_correlations(
+ wifi_devices=wifi_devices,
+ bt_devices=bt_devices,
+ min_confidence=min_confidence,
+ include_historical=include_historical
+ )
+
+ return jsonify({
+ 'status': 'success',
+ 'correlations': correlations,
+ 'wifi_count': len(wifi_devices),
+ 'bt_count': len(bt_devices)
+ })
+ except Exception as e:
+ logger.error(f"Error calculating correlations: {e}")
+ return jsonify({
+ 'status': 'error',
+ 'message': str(e)
+ }), 500
+
+
+@correlation_bp.route('/analyze', methods=['POST'])
+def analyze_correlation() -> Response:
+ """
+ Analyze specific device pair for correlation.
+
+ Request body:
+ wifi_mac: WiFi device MAC address
+ bt_mac: Bluetooth device MAC address
+ """
+ data = request.json or {}
+ wifi_mac = data.get('wifi_mac')
+ bt_mac = data.get('bt_mac')
+
+ if not wifi_mac or not bt_mac:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'wifi_mac and bt_mac are required'
+ }), 400
+
+ try:
+ # Get device data
+ wifi_device = app_module.wifi_networks.get(wifi_mac)
+ if not wifi_device:
+ wifi_device = app_module.wifi_clients.get(wifi_mac)
+
+ bt_device = app_module.bt_devices.get(bt_mac)
+
+ if not wifi_device:
+ return jsonify({
+ 'status': 'error',
+ 'message': f'WiFi device {wifi_mac} not found'
+ }), 404
+
+ if not bt_device:
+ return jsonify({
+ 'status': 'error',
+ 'message': f'Bluetooth device {bt_mac} not found'
+ }), 404
+
+ # Calculate correlation for this specific pair
+ correlations = get_correlations(
+ wifi_devices={wifi_mac: wifi_device},
+ bt_devices={bt_mac: bt_device},
+ min_confidence=0.0, # Show even low confidence for analysis
+ include_historical=True
+ )
+
+ if correlations:
+ return jsonify({
+ 'status': 'success',
+ 'correlation': correlations[0]
+ })
+ else:
+ return jsonify({
+ 'status': 'success',
+ 'correlation': None,
+ 'message': 'No correlation detected between these devices'
+ })
+ except Exception as e:
+ logger.error(f"Error analyzing correlation: {e}")
+ return jsonify({
+ 'status': 'error',
+ 'message': str(e)
+ }), 500
diff --git a/routes/listening_post.py b/routes/listening_post.py
new file mode 100644
index 0000000..311382b
--- /dev/null
+++ b/routes/listening_post.py
@@ -0,0 +1,754 @@
+"""Listening Post routes for radio monitoring and frequency scanning."""
+
+from __future__ import annotations
+
+import json
+import os
+import queue
+import shutil
+import subprocess
+import threading
+import time
+from datetime import datetime
+from typing import Generator, Optional, List, Dict
+
+from flask import Blueprint, jsonify, request, Response
+
+from utils.logging import get_logger
+from utils.sse import format_sse
+from utils.constants import (
+ SSE_QUEUE_TIMEOUT,
+ SSE_KEEPALIVE_INTERVAL,
+ PROCESS_TERMINATE_TIMEOUT,
+)
+
+logger = get_logger('intercept.listening_post')
+
+listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening')
+
+# ============================================
+# GLOBAL STATE
+# ============================================
+
+# Audio demodulation state
+audio_process = None
+audio_rtl_process = None
+audio_lock = threading.Lock()
+audio_running = False
+audio_frequency = 0.0
+audio_modulation = 'fm'
+
+# Scanner state
+scanner_thread: Optional[threading.Thread] = None
+scanner_running = False
+scanner_lock = threading.Lock()
+scanner_paused = False
+scanner_current_freq = 0.0
+scanner_config = {
+ 'start_freq': 88.0,
+ 'end_freq': 108.0,
+ 'step': 0.1,
+ 'modulation': 'wfm',
+ 'squelch': 20,
+ 'dwell_time': 10.0, # Seconds to stay on active frequency
+ 'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
+ 'device': 0,
+ 'gain': 40,
+}
+
+# Activity log
+activity_log: List[Dict] = []
+activity_log_lock = threading.Lock()
+MAX_LOG_ENTRIES = 500
+
+# SSE queue for scanner events
+scanner_queue: queue.Queue = queue.Queue(maxsize=100)
+
+
+# ============================================
+# HELPER FUNCTIONS
+# ============================================
+
+def find_rtl_fm() -> str | None:
+ """Find rtl_fm binary."""
+ return shutil.which('rtl_fm')
+
+
+def find_ffmpeg() -> str | None:
+ """Find ffmpeg for audio encoding."""
+ return shutil.which('ffmpeg')
+
+
+def find_sox() -> str | None:
+ """Find sox for audio encoding."""
+ return shutil.which('sox')
+
+
+def add_activity_log(event_type: str, frequency: float, details: str = ''):
+ """Add entry to activity log."""
+ with activity_log_lock:
+ entry = {
+ 'timestamp': datetime.utcnow().isoformat() + 'Z',
+ 'type': event_type,
+ 'frequency': frequency,
+ 'details': details,
+ }
+ activity_log.insert(0, entry)
+ # Trim log
+ while len(activity_log) > MAX_LOG_ENTRIES:
+ activity_log.pop()
+
+ # Also push to SSE queue
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'log',
+ 'entry': entry
+ })
+ except queue.Full:
+ pass
+
+
+# ============================================
+# SCANNER IMPLEMENTATION
+# ============================================
+
+def scanner_loop():
+ """Main scanner loop - scans frequencies looking for signals."""
+ global scanner_running, scanner_paused, scanner_current_freq, scanner_skip_signal
+ global audio_process, audio_rtl_process, audio_running, audio_frequency
+
+ logger.info("Scanner thread started")
+ add_activity_log('scanner_start', scanner_config['start_freq'],
+ f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
+
+ rtl_fm_path = find_rtl_fm()
+
+ if not rtl_fm_path:
+ logger.error("rtl_fm not found")
+ add_activity_log('error', 0, 'rtl_fm not found')
+ scanner_running = False
+ return
+
+ current_freq = scanner_config['start_freq']
+ last_signal_time = 0
+ signal_detected = False
+
+ # Convert step from kHz to MHz
+ step_mhz = scanner_config['step'] / 1000.0
+
+ try:
+ while scanner_running:
+ # Check if paused
+ if scanner_paused:
+ time.sleep(0.1)
+ continue
+
+ scanner_current_freq = current_freq
+
+ # Notify clients of frequency change
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'freq_change',
+ 'frequency': current_freq,
+ 'scanning': not signal_detected
+ })
+ except queue.Full:
+ pass
+
+ # Start rtl_fm at this frequency
+ freq_hz = int(current_freq * 1e6)
+ mod = scanner_config['modulation']
+
+ # Sample rates
+ if mod == 'wfm':
+ sample_rate = 170000
+ resample_rate = 32000
+ elif mod in ['usb', 'lsb']:
+ sample_rate = 12000
+ resample_rate = 12000
+ else:
+ sample_rate = 24000
+ resample_rate = 24000
+
+ # Don't use squelch in rtl_fm - we want to analyze raw audio
+ rtl_cmd = [
+ rtl_fm_path,
+ '-M', mod,
+ '-f', str(freq_hz),
+ '-s', str(sample_rate),
+ '-r', str(resample_rate),
+ '-g', str(scanner_config['gain']),
+ '-d', str(scanner_config['device']),
+ ]
+
+ try:
+ # Start rtl_fm
+ rtl_proc = subprocess.Popen(
+ rtl_cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL
+ )
+
+ # Read audio data for analysis
+ audio_data = b''
+
+ # Read audio samples for a short period
+ sample_duration = 0.25 # 250ms - balance between speed and detection
+ bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono
+
+ while len(audio_data) < bytes_needed and scanner_running:
+ chunk = rtl_proc.stdout.read(4096)
+ if not chunk:
+ break
+ audio_data += chunk
+
+ # Clean up rtl_fm
+ rtl_proc.terminate()
+ try:
+ rtl_proc.wait(timeout=1)
+ except subprocess.TimeoutExpired:
+ rtl_proc.kill()
+
+ # Analyze audio level
+ audio_detected = False
+ rms = 0
+ threshold = 3000
+ if len(audio_data) > 100:
+ import struct
+ samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
+ # Calculate RMS level (root mean square)
+ rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
+
+ # WFM (broadcast FM) has much higher audio output - needs higher threshold
+ # AM/NFM have lower output levels
+ if mod == 'wfm':
+ # WFM: threshold 4000-12000 based on squelch
+ threshold = 4000 + (scanner_config['squelch'] * 80)
+ else:
+ # AM/NFM: threshold 1500-8000 based on squelch
+ threshold = 1500 + (scanner_config['squelch'] * 65)
+
+ audio_detected = rms > threshold
+
+ # Send level info to clients
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'scan_update',
+ 'frequency': current_freq,
+ 'level': int(rms),
+ 'threshold': int(threshold) if 'threshold' in dir() else 0,
+ 'detected': audio_detected
+ })
+ except queue.Full:
+ pass
+
+ if audio_detected and scanner_running:
+ if not signal_detected:
+ # New signal found!
+ signal_detected = True
+ last_signal_time = time.time()
+ add_activity_log('signal_found', current_freq,
+ f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})')
+ logger.info(f"Signal found at {current_freq} MHz")
+
+ # Start audio streaming for user
+ _start_audio_stream(current_freq, mod)
+
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'signal_found',
+ 'frequency': current_freq,
+ 'modulation': mod,
+ 'audio_streaming': True
+ })
+ except queue.Full:
+ pass
+
+ # Check for skip signal
+ if scanner_skip_signal:
+ scanner_skip_signal = False
+ signal_detected = False
+ _stop_audio_stream()
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'signal_skipped',
+ 'frequency': current_freq
+ })
+ except queue.Full:
+ pass
+ # Move to next frequency (step is in kHz, convert to MHz)
+ current_freq += step_mhz
+ if current_freq > scanner_config['end_freq']:
+ current_freq = scanner_config['start_freq']
+ continue
+
+ # Stay on this frequency (dwell) but check periodically
+ dwell_start = time.time()
+ while (time.time() - dwell_start) < scanner_config['dwell_time'] and scanner_running:
+ if scanner_skip_signal:
+ break
+ time.sleep(0.2)
+
+ last_signal_time = time.time()
+
+ else:
+ # No signal at this frequency
+ if signal_detected:
+ # Signal lost
+ duration = time.time() - last_signal_time + scanner_config['dwell_time']
+ add_activity_log('signal_lost', current_freq,
+ f'Signal lost after {duration:.1f}s')
+ signal_detected = False
+
+ # Stop audio
+ _stop_audio_stream()
+
+ try:
+ scanner_queue.put_nowait({
+ 'type': 'signal_lost',
+ 'frequency': current_freq
+ })
+ except queue.Full:
+ pass
+
+ # Move to next frequency (step is in kHz, convert to MHz)
+ current_freq += step_mhz
+ if current_freq > scanner_config['end_freq']:
+ current_freq = scanner_config['start_freq']
+ add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
+
+ time.sleep(scanner_config['scan_delay'])
+
+ except Exception as e:
+ logger.error(f"Scanner error at {current_freq} MHz: {e}")
+ time.sleep(0.5)
+
+ except Exception as e:
+ logger.error(f"Scanner loop error: {e}")
+ finally:
+ scanner_running = False
+ _stop_audio_stream()
+ add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
+ logger.info("Scanner thread stopped")
+
+
+def _start_audio_stream(frequency: float, modulation: str):
+ """Start audio streaming at given frequency."""
+ global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
+
+ with audio_lock:
+ # Stop any existing stream
+ _stop_audio_stream_internal()
+
+ rtl_fm_path = find_rtl_fm()
+ ffmpeg_path = find_ffmpeg()
+
+ if not rtl_fm_path or not ffmpeg_path:
+ return
+
+ freq_hz = int(frequency * 1e6)
+
+ if modulation == 'wfm':
+ sample_rate = 170000
+ resample_rate = 32000
+ elif modulation in ['usb', 'lsb']:
+ sample_rate = 12000
+ resample_rate = 12000
+ else:
+ sample_rate = 24000
+ resample_rate = 24000
+
+ rtl_cmd = [
+ rtl_fm_path,
+ '-M', modulation,
+ '-f', str(freq_hz),
+ '-s', str(sample_rate),
+ '-r', str(resample_rate),
+ '-g', str(scanner_config['gain']),
+ '-d', str(scanner_config['device']),
+ '-l', str(scanner_config['squelch']),
+ ]
+
+ encoder_cmd = [
+ ffmpeg_path,
+ '-f', 's16le',
+ '-ar', str(resample_rate),
+ '-ac', '1',
+ '-i', 'pipe:0',
+ '-f', 'mp3',
+ '-b:a', '64k',
+ '-flush_packets', '1',
+ 'pipe:1'
+ ]
+
+ try:
+ audio_rtl_process = subprocess.Popen(
+ rtl_cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL
+ )
+
+ audio_process = subprocess.Popen(
+ encoder_cmd,
+ stdin=audio_rtl_process.stdout,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ bufsize=0
+ )
+
+ audio_rtl_process.stdout.close()
+ audio_running = True
+ audio_frequency = frequency
+ audio_modulation = modulation
+
+ except Exception as e:
+ logger.error(f"Failed to start audio stream: {e}")
+
+
+def _stop_audio_stream():
+ """Stop audio streaming."""
+ with audio_lock:
+ _stop_audio_stream_internal()
+
+
+def _stop_audio_stream_internal():
+ """Internal stop (must hold lock)."""
+ global audio_process, audio_rtl_process, audio_running, audio_frequency
+
+ if audio_process:
+ try:
+ audio_process.terminate()
+ audio_process.wait(timeout=1)
+ except:
+ try:
+ audio_process.kill()
+ except:
+ pass
+ audio_process = None
+
+ if audio_rtl_process:
+ try:
+ audio_rtl_process.terminate()
+ audio_rtl_process.wait(timeout=1)
+ except:
+ try:
+ audio_rtl_process.kill()
+ except:
+ pass
+ audio_rtl_process = None
+
+ audio_running = False
+ audio_frequency = 0.0
+
+
+# ============================================
+# API ENDPOINTS
+# ============================================
+
+@listening_post_bp.route('/tools')
+def check_tools() -> Response:
+ """Check for required tools."""
+ rtl_fm = find_rtl_fm()
+ ffmpeg = find_ffmpeg()
+ sox = find_sox()
+ can_stream = ffmpeg is not None or sox is not None
+
+ return jsonify({
+ 'rtl_fm': rtl_fm is not None,
+ 'ffmpeg': ffmpeg is not None,
+ 'sox': sox is not None,
+ 'can_stream': can_stream,
+ 'available': rtl_fm is not None and can_stream
+ })
+
+
+@listening_post_bp.route('/scanner/start', methods=['POST'])
+def start_scanner() -> Response:
+ """Start the frequency scanner."""
+ global scanner_thread, scanner_running, scanner_config
+
+ with scanner_lock:
+ if scanner_running:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Scanner already running'
+ }), 409
+
+ data = request.json or {}
+
+ # Update scanner config
+ try:
+ scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
+ scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
+ scanner_config['step'] = float(data.get('step', 0.1))
+ scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
+ scanner_config['squelch'] = int(data.get('squelch', 20))
+ scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
+ scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
+ scanner_config['device'] = int(data.get('device', 0))
+ scanner_config['gain'] = int(data.get('gain', 40))
+ except (ValueError, TypeError) as e:
+ return jsonify({
+ 'status': 'error',
+ 'message': f'Invalid parameter: {e}'
+ }), 400
+
+ # Validate
+ if scanner_config['start_freq'] >= scanner_config['end_freq']:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'start_freq must be less than end_freq'
+ }), 400
+
+ # Check tools
+ if not find_rtl_fm():
+ return jsonify({
+ 'status': 'error',
+ 'message': 'rtl_fm not found. Install rtl-sdr tools.'
+ }), 503
+
+ # Start scanner thread
+ scanner_running = True
+ scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
+ scanner_thread.start()
+
+ return jsonify({
+ 'status': 'started',
+ 'config': scanner_config
+ })
+
+
+@listening_post_bp.route('/scanner/stop', methods=['POST'])
+def stop_scanner() -> Response:
+ """Stop the frequency scanner."""
+ global scanner_running
+
+ scanner_running = False
+ _stop_audio_stream()
+
+ return jsonify({'status': 'stopped'})
+
+
+@listening_post_bp.route('/scanner/pause', methods=['POST'])
+def pause_scanner() -> Response:
+ """Pause/resume the scanner."""
+ global scanner_paused
+
+ scanner_paused = not scanner_paused
+
+ if scanner_paused:
+ add_activity_log('scanner_pause', scanner_current_freq, 'Scanner paused')
+ else:
+ add_activity_log('scanner_resume', scanner_current_freq, 'Scanner resumed')
+
+ return jsonify({
+ 'status': 'paused' if scanner_paused else 'resumed',
+ 'paused': scanner_paused
+ })
+
+
+# Flag to trigger skip from API
+scanner_skip_signal = False
+
+
+@listening_post_bp.route('/scanner/skip', methods=['POST'])
+def skip_signal() -> Response:
+ """Skip current signal and continue scanning."""
+ global scanner_skip_signal
+
+ if not scanner_running:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Scanner not running'
+ }), 400
+
+ scanner_skip_signal = True
+ add_activity_log('signal_skip', scanner_current_freq, f'Skipped signal at {scanner_current_freq:.3f} MHz')
+
+ return jsonify({
+ 'status': 'skipped',
+ 'frequency': scanner_current_freq
+ })
+
+
+@listening_post_bp.route('/scanner/status')
+def scanner_status() -> Response:
+ """Get scanner status."""
+ return jsonify({
+ 'running': scanner_running,
+ 'paused': scanner_paused,
+ 'current_freq': scanner_current_freq,
+ 'config': scanner_config,
+ 'audio_streaming': audio_running,
+ 'audio_frequency': audio_frequency
+ })
+
+
+@listening_post_bp.route('/scanner/stream')
+def stream_scanner_events() -> Response:
+ """SSE stream for scanner events."""
+ def generate() -> Generator[str, None, None]:
+ last_keepalive = time.time()
+
+ while True:
+ try:
+ msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
+ last_keepalive = time.time()
+ yield format_sse(msg)
+ except queue.Empty:
+ now = time.time()
+ if now - last_keepalive >= SSE_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'
+ return response
+
+
+@listening_post_bp.route('/scanner/log')
+def get_activity_log() -> Response:
+ """Get activity log."""
+ limit = request.args.get('limit', 100, type=int)
+ with activity_log_lock:
+ return jsonify({
+ 'log': activity_log[:limit],
+ 'total': len(activity_log)
+ })
+
+
+@listening_post_bp.route('/scanner/log/clear', methods=['POST'])
+def clear_activity_log() -> Response:
+ """Clear activity log."""
+ with activity_log_lock:
+ activity_log.clear()
+ return jsonify({'status': 'cleared'})
+
+
+@listening_post_bp.route('/presets')
+def get_presets() -> Response:
+ """Get scanner presets."""
+ presets = [
+ {'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'},
+ {'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'},
+ {'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'},
+ {'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'},
+ {'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'},
+ {'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'},
+ {'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'},
+ {'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'},
+ ]
+ return jsonify({'presets': presets})
+
+
+# ============================================
+# MANUAL AUDIO ENDPOINTS (for direct listening)
+# ============================================
+
+@listening_post_bp.route('/audio/start', methods=['POST'])
+def start_audio() -> Response:
+ """Start audio at specific frequency (manual mode)."""
+ global scanner_running
+
+ # Stop scanner if running
+ if scanner_running:
+ scanner_running = False
+ time.sleep(0.5)
+
+ data = request.json or {}
+
+ try:
+ frequency = float(data.get('frequency', 0))
+ modulation = str(data.get('modulation', 'wfm')).lower()
+ squelch = int(data.get('squelch', 0))
+ gain = int(data.get('gain', 40))
+ device = int(data.get('device', 0))
+ except (ValueError, TypeError) as e:
+ return jsonify({
+ 'status': 'error',
+ 'message': f'Invalid parameter: {e}'
+ }), 400
+
+ if frequency <= 0:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'frequency is required'
+ }), 400
+
+ valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb']
+ if modulation not in valid_mods:
+ return jsonify({
+ 'status': 'error',
+ 'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
+ }), 400
+
+ # Update config for audio
+ scanner_config['squelch'] = squelch
+ scanner_config['gain'] = gain
+ scanner_config['device'] = device
+
+ _start_audio_stream(frequency, modulation)
+
+ if audio_running:
+ add_activity_log('manual_tune', frequency, f'Manual tune to {frequency} MHz ({modulation.upper()})')
+ return jsonify({
+ 'status': 'started',
+ 'frequency': frequency,
+ 'modulation': modulation,
+ 'stream_url': '/listening/audio/stream'
+ })
+ else:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Failed to start audio'
+ }), 500
+
+
+@listening_post_bp.route('/audio/stop', methods=['POST'])
+def stop_audio() -> Response:
+ """Stop audio."""
+ _stop_audio_stream()
+ return jsonify({'status': 'stopped'})
+
+
+@listening_post_bp.route('/audio/status')
+def audio_status() -> Response:
+ """Get audio status."""
+ return jsonify({
+ 'running': audio_running,
+ 'frequency': audio_frequency,
+ 'modulation': audio_modulation
+ })
+
+
+@listening_post_bp.route('/audio/stream')
+def stream_audio() -> Response:
+ """Stream MP3 audio."""
+ if not audio_running or not audio_process:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Audio not running'
+ }), 400
+
+ def generate():
+ chunk_size = 4096
+ try:
+ while audio_running and audio_process and audio_process.poll() is None:
+ chunk = audio_process.stdout.read(chunk_size)
+ if not chunk:
+ break
+ yield chunk
+ except Exception as e:
+ logger.error(f"Audio stream error: {e}")
+
+ return Response(
+ generate(),
+ mimetype='audio/mpeg',
+ headers={
+ 'Content-Type': 'audio/mpeg',
+ 'Cache-Control': 'no-cache, no-store',
+ 'X-Accel-Buffering': 'no',
+ 'Transfer-Encoding': 'chunked',
+ }
+ )
diff --git a/routes/settings.py b/routes/settings.py
new file mode 100644
index 0000000..8f4d3c2
--- /dev/null
+++ b/routes/settings.py
@@ -0,0 +1,228 @@
+"""Settings management routes."""
+
+from __future__ import annotations
+
+from flask import Blueprint, jsonify, request, Response
+
+from utils.database import (
+ get_setting,
+ set_setting,
+ delete_setting,
+ get_all_settings,
+ get_signal_history,
+ add_signal_reading,
+ get_correlations,
+)
+from utils.logging import get_logger
+
+logger = get_logger('intercept.settings')
+
+settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
+
+
+@settings_bp.route('', methods=['GET'])
+def get_settings() -> Response:
+ """Get all settings."""
+ try:
+ settings = get_all_settings()
+ return jsonify({
+ 'status': 'success',
+ 'settings': settings
+ })
+ except Exception as e:
+ logger.error(f"Error getting settings: {e}")
+ return jsonify({
+ 'status': 'error',
+ 'message': str(e)
+ }), 500
+
+
+@settings_bp.route('', methods=['POST'])
+def save_settings() -> Response:
+ """Save one or more settings."""
+ data = request.json or {}
+
+ if not data:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'No settings provided'
+ }), 400
+
+ try:
+ saved = []
+ for key, value in data.items():
+ # Validate key (alphanumeric, underscores, dots, hyphens)
+ if not key or not all(c.isalnum() or c in '_.-' for c in key):
+ continue
+
+ set_setting(key, value)
+ saved.append(key)
+
+ return jsonify({
+ 'status': 'success',
+ 'saved': saved
+ })
+ except Exception as e:
+ logger.error(f"Error saving settings: {e}")
+ return jsonify({
+ 'status': 'error',
+ 'message': str(e)
+ }), 500
+
+
+@settings_bp.route('/', methods=['GET'])
+def get_single_setting(key: str) -> Response:
+ """Get a single setting by key."""
+ try:
+ value = get_setting(key)
+ if value is None:
+ return jsonify({
+ 'status': 'not_found',
+ 'key': key
+ }), 404
+
+ return jsonify({
+ 'status': 'success',
+ 'key': key,
+ 'value': value
+ })
+ except Exception as e:
+ logger.error(f"Error getting setting {key}: {e}")
+ return jsonify({
+ 'status': 'error',
+ 'message': str(e)
+ }), 500
+
+
+@settings_bp.route('/', methods=['PUT'])
+def update_single_setting(key: str) -> Response:
+ """Update a single setting."""
+ data = request.json or {}
+ value = data.get('value')
+
+ if value is None and 'value' not in data:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Value is required'
+ }), 400
+
+ try:
+ set_setting(key, value)
+ return jsonify({
+ 'status': 'success',
+ 'key': key,
+ 'value': value
+ })
+ except Exception as e:
+ logger.error(f"Error updating setting {key}: {e}")
+ return jsonify({
+ 'status': 'error',
+ 'message': str(e)
+ }), 500
+
+
+@settings_bp.route('/', methods=['DELETE'])
+def delete_single_setting(key: str) -> Response:
+ """Delete a setting."""
+ try:
+ deleted = delete_setting(key)
+ if deleted:
+ return jsonify({
+ 'status': 'success',
+ 'key': key,
+ 'deleted': True
+ })
+ else:
+ return jsonify({
+ 'status': 'not_found',
+ 'key': key
+ }), 404
+ except Exception as e:
+ logger.error(f"Error deleting setting {key}: {e}")
+ return jsonify({
+ 'status': 'error',
+ 'message': str(e)
+ }), 500
+
+
+# =============================================================================
+# Signal History Endpoints
+# =============================================================================
+
+@settings_bp.route('/signal-history//', methods=['GET'])
+def get_device_signal_history(mode: str, device_id: str) -> Response:
+ """Get signal strength history for a device."""
+ limit = request.args.get('limit', 100, type=int)
+ since_minutes = request.args.get('since', 60, type=int)
+
+ # Validate mode
+ valid_modes = ['wifi', 'bluetooth', 'adsb', 'pager', 'sensor']
+ if mode not in valid_modes:
+ return jsonify({
+ 'status': 'error',
+ 'message': f'Invalid mode. Valid modes: {valid_modes}'
+ }), 400
+
+ try:
+ history = get_signal_history(mode, device_id, limit, since_minutes)
+ return jsonify({
+ 'status': 'success',
+ 'mode': mode,
+ 'device_id': device_id,
+ 'history': history
+ })
+ except Exception as e:
+ logger.error(f"Error getting signal history: {e}")
+ return jsonify({
+ 'status': 'error',
+ 'message': str(e)
+ }), 500
+
+
+@settings_bp.route('/signal-history', methods=['POST'])
+def add_signal_history() -> Response:
+ """Add a signal strength reading (for internal use)."""
+ data = request.json or {}
+
+ mode = data.get('mode')
+ device_id = data.get('device_id')
+ signal_strength = data.get('signal_strength')
+
+ if not all([mode, device_id, signal_strength is not None]):
+ return jsonify({
+ 'status': 'error',
+ 'message': 'mode, device_id, and signal_strength are required'
+ }), 400
+
+ try:
+ add_signal_reading(mode, device_id, signal_strength, data.get('metadata'))
+ return jsonify({'status': 'success'})
+ except Exception as e:
+ logger.error(f"Error adding signal reading: {e}")
+ return jsonify({
+ 'status': 'error',
+ 'message': str(e)
+ }), 500
+
+
+# =============================================================================
+# Device Correlation Endpoints
+# =============================================================================
+
+@settings_bp.route('/correlations', methods=['GET'])
+def get_device_correlations() -> Response:
+ """Get device correlations between WiFi and Bluetooth."""
+ min_confidence = request.args.get('min_confidence', 0.5, type=float)
+
+ try:
+ correlations = get_correlations(min_confidence)
+ return jsonify({
+ 'status': 'success',
+ 'correlations': correlations
+ })
+ except Exception as e:
+ logger.error(f"Error getting correlations: {e}")
+ return jsonify({
+ 'status': 'error',
+ 'message': str(e)
+ }), 500
diff --git a/routes/wifi.py b/routes/wifi.py
index 79a6223..7abe886 100644
--- a/routes/wifi.py
+++ b/routes/wifi.py
@@ -22,6 +22,26 @@ from utils.process import is_valid_mac, is_valid_channel
from utils.validation import validate_wifi_channel, validate_mac_address
from utils.sse import format_sse
from data.oui import get_manufacturer
+from utils.constants import (
+ WIFI_TERMINATE_TIMEOUT,
+ PMKID_TERMINATE_TIMEOUT,
+ SSE_KEEPALIVE_INTERVAL,
+ SSE_QUEUE_TIMEOUT,
+ WIFI_CSV_PARSE_INTERVAL,
+ WIFI_CSV_TIMEOUT_WARNING,
+ SUBPROCESS_TIMEOUT_SHORT,
+ SUBPROCESS_TIMEOUT_MEDIUM,
+ SUBPROCESS_TIMEOUT_LONG,
+ DEAUTH_TIMEOUT,
+ MIN_DEAUTH_COUNT,
+ MAX_DEAUTH_COUNT,
+ DEFAULT_DEAUTH_COUNT,
+ PROCESS_START_WAIT,
+ MONITOR_MODE_DELAY,
+ WIFI_CAPTURE_PATH_PREFIX,
+ HANDSHAKE_CAPTURE_PATH_PREFIX,
+ PMKID_CAPTURE_PATH_PREFIX,
+)
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
@@ -37,7 +57,7 @@ def detect_wifi_interfaces():
if platform.system() == 'Darwin': # macOS
try:
result = subprocess.run(['networksetup', '-listallhardwareports'],
- capture_output=True, text=True, timeout=5)
+ capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
lines = result.stdout.split('\n')
for i, line in enumerate(lines):
if 'Wi-Fi' in line or 'AirPort' in line:
@@ -51,12 +71,16 @@ def detect_wifi_interfaces():
'status': 'up'
})
break
- except Exception as e:
+ except FileNotFoundError:
+ logger.debug("networksetup not found")
+ except subprocess.TimeoutExpired:
+ logger.warning("networksetup timed out")
+ except subprocess.SubprocessError as e:
logger.error(f"Error detecting macOS interfaces: {e}")
try:
result = subprocess.run(['system_profiler', 'SPUSBDataType'],
- capture_output=True, text=True, timeout=10)
+ capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_MEDIUM)
if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
interfaces.append({
'name': 'USB WiFi Adapter',
@@ -64,12 +88,16 @@ def detect_wifi_interfaces():
'monitor_capable': True,
'status': 'detected'
})
- except Exception:
- pass
+ except FileNotFoundError:
+ logger.debug("system_profiler not found")
+ except subprocess.TimeoutExpired:
+ logger.debug("system_profiler timed out")
+ except subprocess.SubprocessError as e:
+ logger.debug(f"Error running system_profiler: {e}")
else: # Linux
try:
- result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5)
+ result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
current_iface = None
for line in result.stdout.split('\n'):
line = line.strip()
@@ -85,8 +113,9 @@ def detect_wifi_interfaces():
})
current_iface = None
except FileNotFoundError:
+ # Fall back to iwconfig if iw is not available
try:
- result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
+ result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
for line in result.stdout.split('\n'):
if 'IEEE 802.11' in line:
iface = line.split()[0]
@@ -96,9 +125,13 @@ def detect_wifi_interfaces():
'monitor_capable': True,
'status': 'up'
})
- except Exception:
- pass
- except Exception as e:
+ except FileNotFoundError:
+ logger.debug("Neither iw nor iwconfig found")
+ except subprocess.SubprocessError as e:
+ logger.debug(f"Error running iwconfig: {e}")
+ except subprocess.TimeoutExpired:
+ logger.warning("iw command timed out")
+ except subprocess.SubprocessError as e:
logger.error(f"Error detecting Linux interfaces: {e}")
return interfaces
diff --git a/setup.sh b/setup.sh
index d1af18f..80b3871 100755
--- a/setup.sh
+++ b/setup.sh
@@ -1,7 +1,7 @@
#!/bin/bash
#
# INTERCEPT Setup Script
-# Installs Python dependencies and checks for external tools
+# Installs dependencies for macOS and Debian/Ubuntu
#
set -e
@@ -32,17 +32,11 @@ detect_os() {
elif [[ -f /etc/debian_version ]]; then
OS="debian"
PKG_MANAGER="apt"
- elif [[ -f /etc/redhat-release ]]; then
- OS="redhat"
- PKG_MANAGER="dnf"
- elif [[ -f /etc/arch-release ]]; then
- OS="arch"
- PKG_MANAGER="pacman"
else
OS="unknown"
PKG_MANAGER="unknown"
fi
- echo -e "${BLUE}Detected OS:${NC} $OS (package manager: $PKG_MANAGER)"
+ echo -e "${BLUE}Detected OS:${NC} $OS"
}
# Check if a command exists
@@ -50,14 +44,14 @@ check_cmd() {
command -v "$1" &> /dev/null
}
-# Check if a package is installable (has a candidate version)
+# Check if a package is installable (Debian)
pkg_available() {
local candidate
candidate=$(apt-cache policy "$1" 2>/dev/null | grep "Candidate:" | awk '{print $2}')
[ -n "$candidate" ] && [ "$candidate" != "(none)" ]
}
-# Setup sudo command (empty if running as root)
+# Setup sudo command
setup_sudo() {
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
@@ -66,46 +60,44 @@ setup_sudo() {
SUDO="sudo"
else
echo -e "${RED}Error: Not running as root and sudo is not installed${NC}"
- echo ""
- echo "Please either:"
- echo " 1. Run this script as root: su -c './setup.sh'"
- echo " 2. Install sudo: apt install sudo"
exit 1
fi
}
-# Install Python dependencies
+# ============================================
+# PYTHON DEPENDENCIES
+# ============================================
install_python_deps() {
echo ""
echo -e "${BLUE}[1/3] Installing Python dependencies...${NC}"
if ! check_cmd python3; then
echo -e "${RED}Error: Python 3 is not installed${NC}"
- echo "Please install Python 3.9 or later"
+ if [[ "$OS" == "macos" ]]; then
+ echo "Install with: brew install python@3.11"
+ else
+ echo "Install with: sudo apt install python3"
+ fi
exit 1
fi
- # Check Python version (need 3.9+)
+ # Check Python version
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')
echo "Python version: $PYTHON_VERSION"
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 9 ]); then
- echo -e "${RED}Error: Python 3.9 or later is required${NC}"
- echo "You have Python $PYTHON_VERSION"
- echo ""
- echo "Please upgrade Python:"
- if [ -n "$SUDO" ]; then
- echo " Ubuntu/Debian: sudo apt install python3.11"
+ echo -e "${RED}Error: Python 3.9 or later is required (you have $PYTHON_VERSION)${NC}"
+ if [[ "$OS" == "macos" ]]; then
+ echo "Upgrade with: brew install python@3.11"
else
- echo " Ubuntu/Debian: apt install python3.11"
+ echo "Upgrade with: sudo apt install python3.11"
fi
- echo " macOS: brew install python@3.11"
exit 1
fi
- # Check if we're in a virtual environment
+ # Install dependencies
if [ -n "$VIRTUAL_ENV" ]; then
echo "Using virtual environment: $VIRTUAL_ENV"
pip install -r requirements.txt
@@ -114,54 +106,59 @@ install_python_deps() {
source venv/bin/activate
pip install -r requirements.txt
else
- # Try direct pip install first, fall back to venv if it fails (PEP 668)
- echo "Attempting to install dependencies..."
+ # Try pip install, fall back to venv if needed (PEP 668)
if python3 -m pip install -r requirements.txt 2>/dev/null; then
- echo -e "${GREEN}Python dependencies installed successfully${NC}"
+ echo -e "${GREEN}Python dependencies installed${NC}"
return
fi
- # If pip install failed (likely PEP 668), create a virtual environment
- echo ""
- echo -e "${YELLOW}System Python is externally managed (PEP 668).${NC}"
- echo "Creating virtual environment..."
-
- # Remove any incomplete venv directory from previous failed attempts
+ echo -e "${YELLOW}Creating virtual environment...${NC}"
if [ -d "venv" ] && [ ! -f "venv/bin/activate" ]; then
- echo "Removing incomplete venv directory..."
rm -rf venv
fi
if ! python3 -m venv venv; then
echo -e "${RED}Error: Failed to create virtual environment${NC}"
- echo ""
- echo "On Debian/Ubuntu, install the venv module with:"
- if [ -n "$SUDO" ]; then
- echo " sudo apt install python3-venv"
- else
- echo " apt install python3-venv"
+ if [[ "$OS" == "debian" ]]; then
+ echo "Install with: sudo apt install python3-venv"
fi
- echo ""
- echo "Then run this setup script again."
exit 1
fi
+
source venv/bin/activate
pip install -r requirements.txt
+
echo ""
- echo -e "${YELLOW}NOTE: A virtual environment was created.${NC}"
- echo "You must activate it before running INTERCEPT:"
- echo " source venv/bin/activate"
- if [ -n "$SUDO" ]; then
- echo " sudo venv/bin/python intercept.py"
- else
- echo " venv/bin/python intercept.py"
- fi
+ echo -e "${YELLOW}NOTE: Virtual environment created.${NC}"
+ echo "Activate with: source venv/bin/activate"
fi
- echo -e "${GREEN}Python dependencies installed successfully${NC}"
+ echo -e "${GREEN}Python dependencies installed${NC}"
+}
+
+# ============================================
+# TOOL CHECKING
+# ============================================
+check_tool() {
+ local cmd=$1
+ local desc=$2
+ local category=$3
+ if check_cmd "$cmd"; then
+ echo -e " ${GREEN}✓${NC} $cmd - $desc"
+ return 0
+ else
+ echo -e " ${RED}✗${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
+ MISSING_TOOLS+=("$cmd")
+ case "$category" in
+ core) MISSING_CORE=true ;;
+ audio) MISSING_AUDIO=true ;;
+ wifi) MISSING_WIFI=true ;;
+ bluetooth) MISSING_BLUETOOTH=true ;;
+ esac
+ return 1
+ fi
}
-# Check external tools
check_tools() {
echo ""
echo -e "${BLUE}[2/3] Checking external tools...${NC}"
@@ -169,10 +166,10 @@ check_tools() {
MISSING_TOOLS=()
MISSING_CORE=false
+ MISSING_AUDIO=false
MISSING_WIFI=false
MISSING_BLUETOOTH=false
- # Core SDR tools
echo "Core SDR Tools:"
check_tool "rtl_fm" "RTL-SDR FM demodulator" "core"
check_tool "rtl_test" "RTL-SDR device detection" "core"
@@ -181,48 +178,51 @@ check_tools() {
check_tool "dump1090" "ADS-B decoder" "core"
echo ""
- echo "Additional SDR Hardware (optional):"
- check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)" "optional"
- check_tool "LimeUtil" "LimeSDR tools" "optional"
- check_tool "hackrf_info" "HackRF tools" "optional"
+ echo "Audio Tools:"
+ check_tool "sox" "Audio player/processor" "audio"
+ # ffmpeg is optional alternative to sox
+ if check_cmd ffmpeg; then
+ echo -e " ${GREEN}✓${NC} ffmpeg - Audio encoder (optional)"
+ fi
echo ""
echo "WiFi Tools:"
check_tool "airmon-ng" "WiFi monitor mode" "wifi"
check_tool "airodump-ng" "WiFi scanner" "wifi"
+ # aireplay-ng is optional (for deauth)
+ if check_cmd aireplay-ng; then
+ echo -e " ${GREEN}✓${NC} aireplay-ng - Deauthentication (optional)"
+ fi
echo ""
echo "Bluetooth Tools:"
+ check_tool "hcitool" "Bluetooth scanner" "bluetooth"
check_tool "bluetoothctl" "Bluetooth controller" "bluetooth"
- check_tool "hcitool" "Bluetooth HCI tool" "bluetooth"
+ check_tool "hciconfig" "Bluetooth adapter config" "bluetooth"
+
+ echo ""
+ echo "Optional (LimeSDR/HackRF):"
+ if check_cmd SoapySDRUtil; then
+ echo -e " ${GREEN}✓${NC} SoapySDRUtil - SoapySDR support"
+ else
+ echo -e " ${YELLOW}-${NC} SoapySDRUtil - Not installed (optional)"
+ fi
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then
echo ""
echo -e "${YELLOW}Some tools are missing.${NC}"
- fi
-}
-
-check_tool() {
- local cmd=$1
- local desc=$2
- local category=$3
- if check_cmd "$cmd"; then
- echo -e " ${GREEN}✓${NC} $cmd - $desc"
else
- echo -e " ${RED}✗${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
- MISSING_TOOLS+=("$cmd")
- case "$category" in
- core) MISSING_CORE=true ;;
- wifi) MISSING_WIFI=true ;;
- bluetooth) MISSING_BLUETOOTH=true ;;
- esac
+ echo ""
+ echo -e "${GREEN}All tools installed!${NC}"
fi
}
-# Install tools on Debian/Ubuntu
-install_debian_tools() {
+# ============================================
+# macOS INSTALLATION
+# ============================================
+install_macos_tools() {
echo ""
- echo -e "${BLUE}[3/3] Installing tools...${NC}"
+ echo -e "${BLUE}[3/3] Installing tools (macOS)...${NC}"
echo ""
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
@@ -230,17 +230,114 @@ install_debian_tools() {
return
fi
- echo -e "${YELLOW}The following tool categories need to be installed:${NC}"
+ # Check for Homebrew
+ if ! check_cmd brew; then
+ echo -e "${YELLOW}Homebrew is not installed.${NC}"
+ echo ""
+ read -p "Would you like to install Homebrew? [Y/n] " -n 1 -r
+ echo ""
+
+ if [[ ! $REPLY =~ ^[Nn]$ ]]; then
+ echo "Installing Homebrew..."
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+
+ # Add brew to PATH for this session
+ if [[ -f /opt/homebrew/bin/brew ]]; then
+ eval "$(/opt/homebrew/bin/brew shellenv)"
+ elif [[ -f /usr/local/bin/brew ]]; then
+ eval "$(/usr/local/bin/brew shellenv)"
+ fi
+ else
+ echo ""
+ echo "Skipping tool installation. Install manually with:"
+ show_macos_manual
+ return
+ fi
+ fi
+
+ echo ""
+ echo -e "${YELLOW}The following will be installed:${NC}"
+ $MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl_433, dump1090)"
+ $MISSING_AUDIO && echo " - Audio tools (sox)"
+ $MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
+ echo ""
+
+ read -p "Install missing tools? [Y/n] " -n 1 -r
+ echo ""
+
+ if [[ ! $REPLY =~ ^[Nn]$ ]]; then
+ # Core SDR tools
+ if $MISSING_CORE; then
+ echo ""
+ echo -e "${BLUE}Installing Core SDR tools...${NC}"
+ brew install librtlsdr multimon-ng rtl_433 2>/dev/null || true
+
+ # dump1090
+ if ! check_cmd dump1090; then
+ brew install dump1090-mutability 2>/dev/null || \
+ echo -e "${YELLOW}Note: dump1090 may need manual installation${NC}"
+ fi
+ fi
+
+ # Audio tools
+ if $MISSING_AUDIO; then
+ echo ""
+ echo -e "${BLUE}Installing Audio tools...${NC}"
+ brew install sox
+ fi
+
+ # WiFi tools
+ if $MISSING_WIFI; then
+ echo ""
+ echo -e "${BLUE}Installing WiFi tools...${NC}"
+ brew install aircrack-ng
+ fi
+
+ echo ""
+ echo -e "${GREEN}Tool installation complete!${NC}"
+ else
+ show_macos_manual
+ fi
+}
+
+show_macos_manual() {
+ echo ""
+ echo -e "${BLUE}Manual installation (macOS):${NC}"
+ echo ""
+ echo "# Required tools"
+ echo "brew install librtlsdr multimon-ng rtl_433 sox"
+ echo ""
+ echo "# ADS-B tracking"
+ echo "brew install dump1090-mutability"
+ echo ""
+ echo "# WiFi scanning (optional)"
+ echo "brew install aircrack-ng"
+}
+
+# ============================================
+# DEBIAN INSTALLATION
+# ============================================
+install_debian_tools() {
+ echo ""
+ echo -e "${BLUE}[3/3] Installing tools (Debian/Ubuntu)...${NC}"
+ echo ""
+
+ if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
+ echo -e "${GREEN}All tools are already installed!${NC}"
+ return
+ fi
+
+ echo -e "${YELLOW}The following will be installed:${NC}"
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl-433, dump1090)"
+ $MISSING_AUDIO && echo " - Audio tools (sox)"
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
$MISSING_BLUETOOTH && echo " - Bluetooth tools (bluez)"
echo ""
- read -p "Would you like to install missing tools automatically? [Y/n] " -n 1 -r
+ read -p "Install missing tools? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
- echo ""
echo "Updating package lists..."
$SUDO apt update
@@ -248,34 +345,40 @@ install_debian_tools() {
if $MISSING_CORE; then
echo ""
echo -e "${BLUE}Installing Core SDR tools...${NC}"
+ $SUDO apt install -y rtl-sdr multimon-ng 2>/dev/null || true
- # Install packages that are reliably available
- $SUDO apt install -y rtl-sdr multimon-ng
-
- # rtl-433 may be named differently or unavailable
+ # rtl-433 (package name varies)
if pkg_available rtl-433; then
$SUDO apt install -y rtl-433
elif pkg_available rtl433; then
$SUDO apt install -y rtl433
else
- echo -e "${YELLOW}Note: rtl-433 not found in repositories. Install manually or from source.${NC}"
+ echo -e "${YELLOW}Note: rtl-433 not in repositories, install manually${NC}"
fi
- # dump1090 - try available variants, not available on all Debian versions
- if pkg_available dump1090-fa; then
- $SUDO apt install -y dump1090-fa
- elif pkg_available dump1090-mutability; then
- $SUDO apt install -y dump1090-mutability
- elif pkg_available dump1090; then
- $SUDO apt install -y dump1090
- elif ! check_cmd dump1090; then
- echo ""
- echo -e "${YELLOW}Note: dump1090 not available in your repos (e.g. Debian Trixie).${NC}"
- echo " FlightAware version: https://flightaware.com/adsb/piaware/install"
- echo " Or from source: https://github.com/flightaware/dump1090"
+ # dump1090 (package varies by distribution)
+ if ! check_cmd dump1090; then
+ if pkg_available dump1090-fa; then
+ $SUDO apt install -y dump1090-fa
+ elif pkg_available dump1090-mutability; then
+ $SUDO apt install -y dump1090-mutability
+ elif pkg_available dump1090; then
+ $SUDO apt install -y dump1090
+ else
+ echo ""
+ echo -e "${YELLOW}Note: dump1090 not in repositories.${NC}"
+ echo "Install from: https://flightaware.com/adsb/piaware/install"
+ fi
fi
fi
+ # Audio tools
+ if $MISSING_AUDIO; then
+ echo ""
+ echo -e "${BLUE}Installing Audio tools...${NC}"
+ $SUDO apt install -y sox
+ fi
+
# WiFi tools
if $MISSING_WIFI; then
echo ""
@@ -293,26 +396,37 @@ install_debian_tools() {
echo ""
echo -e "${GREEN}Tool installation complete!${NC}"
- # Setup udev rules automatically
- setup_udev_rules_auto
+ # Setup udev rules
+ setup_udev_rules
else
- echo ""
- echo "Skipping automatic installation."
- show_manual_instructions
+ show_debian_manual
fi
}
-# Setup udev rules automatically (Debian)
-setup_udev_rules_auto() {
+show_debian_manual() {
echo ""
- echo -e "${BLUE}Setting up RTL-SDR udev rules...${NC}"
+ echo -e "${BLUE}Manual installation (Debian/Ubuntu):${NC}"
+ echo ""
+ echo "# Required tools"
+ echo "sudo apt install rtl-sdr multimon-ng rtl-433 sox"
+ echo ""
+ echo "# ADS-B tracking"
+ echo "sudo apt install dump1090-mutability # or dump1090-fa"
+ echo ""
+ echo "# WiFi scanning (optional)"
+ echo "sudo apt install aircrack-ng"
+ echo ""
+ echo "# Bluetooth scanning (optional)"
+ echo "sudo apt install bluez bluetooth"
+}
+setup_udev_rules() {
if [ -f /etc/udev/rules.d/20-rtlsdr.rules ]; then
- echo "udev rules already exist, skipping."
return
fi
- read -p "Would you like to setup RTL-SDR udev rules? [Y/n] " -n 1 -r
+ echo ""
+ read -p "Setup RTL-SDR udev rules? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
@@ -323,133 +437,32 @@ EOF'
$SUDO udevadm control --reload-rules
$SUDO udevadm trigger
echo -e "${GREEN}udev rules installed!${NC}"
- echo "Please unplug and replug your RTL-SDR device."
+ echo "Please unplug and replug your RTL-SDR."
fi
}
-# Show manual installation instructions
-show_manual_instructions() {
- echo ""
- echo -e "${BLUE}Manual installation instructions:${NC}"
- echo ""
-
- if [[ "$OS" == "macos" ]]; then
- echo -e "${YELLOW}macOS (Homebrew):${NC}"
- echo ""
-
- if ! check_cmd brew; then
- echo "First, install Homebrew:"
- echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
- echo ""
- fi
-
- echo "# Core SDR tools"
- echo "brew install librtlsdr multimon-ng rtl_433 dump1090-mutability"
- echo ""
- echo "# LimeSDR support (optional)"
- echo "brew install soapysdr limesuite soapylms7"
- echo ""
- echo "# HackRF support (optional)"
- echo "brew install hackrf soapyhackrf"
- echo ""
- echo "# WiFi tools"
- echo "brew install aircrack-ng"
-
- elif [[ "$OS" == "debian" ]]; then
- echo -e "${YELLOW}Ubuntu/Debian:${NC}"
- echo ""
- echo "# Core SDR tools"
- echo "sudo apt update"
- echo "sudo apt install rtl-sdr multimon-ng rtl-433"
- echo ""
- echo "# dump1090 (try one of these - package name varies):"
- echo "sudo apt install dump1090-fa # FlightAware version"
- echo "# Or install from: https://flightaware.com/adsb/piaware/install"
- echo ""
- echo "# LimeSDR support (optional)"
- echo "sudo apt install soapysdr-tools limesuite soapysdr-module-lms7"
- echo ""
- echo "# HackRF support (optional)"
- echo "sudo apt install hackrf soapysdr-module-hackrf"
- echo ""
- echo "# WiFi tools"
- echo "sudo apt install aircrack-ng"
- echo ""
- echo "# Bluetooth tools"
- echo "sudo apt install bluez bluetooth"
-
- elif [[ "$OS" == "arch" ]]; then
- echo -e "${YELLOW}Arch Linux:${NC}"
- echo ""
- echo "# Core SDR tools"
- echo "sudo pacman -S rtl-sdr multimon-ng"
- echo "yay -S rtl_433 dump1090"
- echo ""
- echo "# LimeSDR/HackRF support (optional)"
- echo "sudo pacman -S soapysdr limesuite hackrf"
-
- elif [[ "$OS" == "redhat" ]]; then
- echo -e "${YELLOW}Fedora/RHEL:${NC}"
- echo ""
- echo "# Core SDR tools"
- echo "sudo dnf install rtl-sdr"
- echo "# multimon-ng, rtl_433, dump1090 may need to be built from source"
-
- else
- echo "Please install the following tools manually:"
- for tool in "${MISSING_TOOLS[@]}"; do
- echo " - $tool"
- done
- fi
-}
-
-# Show installation instructions (decides auto vs manual)
-install_or_show_instructions() {
- if [[ "$OS" == "debian" ]]; then
- install_debian_tools
- else
- echo ""
- echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}"
- if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
- echo ""
- echo -e "${GREEN}All tools are installed!${NC}"
- else
- show_manual_instructions
- fi
- fi
-}
-
-# RTL-SDR udev rules (Linux only)
-setup_udev_rules() {
- if [[ "$OS" != "macos" ]] && [[ "$OS" != "unknown" ]]; then
- echo ""
- echo -e "${BLUE}RTL-SDR udev rules (Linux only):${NC}"
- echo ""
- echo "If your RTL-SDR is not detected, you may need to add udev rules:"
- echo ""
- echo "sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF"
- echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"'
- echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"'
- echo "EOF'"
- echo ""
- echo "sudo udevadm control --reload-rules"
- echo "sudo udevadm trigger"
- echo ""
- echo "Then unplug and replug your RTL-SDR device."
- fi
-}
-
-# Main
+# ============================================
+# MAIN
+# ============================================
main() {
detect_os
- setup_sudo
+
+ if [[ "$OS" == "unknown" ]]; then
+ echo -e "${RED}Unsupported OS. This script supports macOS and Debian/Ubuntu.${NC}"
+ exit 1
+ fi
+
+ if [[ "$OS" == "debian" ]]; then
+ setup_sudo
+ fi
+
install_python_deps
check_tools
- install_or_show_instructions
- # Show udev rules instructions for non-Debian Linux (Debian handles it automatically)
- if [[ "$OS" != "debian" ]]; then
- setup_udev_rules
+ if [[ "$OS" == "macos" ]]; then
+ install_macos_tools
+ else
+ install_debian_tools
fi
echo ""
@@ -457,20 +470,22 @@ main() {
echo -e "${GREEN}Setup complete!${NC}"
echo ""
echo "To start INTERCEPT:"
+
if [ -d "venv" ]; then
echo " source venv/bin/activate"
- if [ -n "$SUDO" ]; then
+ if [[ "$OS" == "debian" ]]; then
echo " sudo venv/bin/python intercept.py"
else
- echo " venv/bin/python intercept.py"
+ echo " sudo python3 intercept.py"
fi
else
- if [ -n "$SUDO" ]; then
+ if [[ "$OS" == "debian" ]]; then
echo " sudo python3 intercept.py"
else
- echo " python3 intercept.py"
+ echo " sudo python3 intercept.py"
fi
fi
+
echo ""
echo "Then open http://localhost:5050 in your browser"
echo ""
diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css
index 8c1189c..6437564 100644
--- a/static/css/adsb_dashboard.css
+++ b/static/css/adsb_dashboard.css
@@ -5,24 +5,27 @@
}
:root {
- --bg-dark: #0a0a0f;
- --bg-panel: #0d1117;
- --bg-card: #161b22;
- --border-glow: #00ff88;
- --text-primary: #e6edf3;
- --text-secondary: #8b949e;
- --accent-green: #00ff88;
- --accent-cyan: #00d4ff;
- --accent-orange: #ff9500;
- --accent-red: #ff4444;
- --accent-yellow: #ffcc00;
- --grid-line: rgba(0, 255, 136, 0.1);
- --radar-cyan: #00ffff;
- --radar-bg: #1a1a2e;
+ --bg-dark: #0a0c10;
+ --bg-panel: #0f1218;
+ --bg-card: #151a23;
+ --border-color: #1f2937;
+ --border-glow: #4a9eff;
+ --text-primary: #e8eaed;
+ --text-secondary: #9ca3af;
+ --text-dim: #4b5563;
+ --accent-green: #22c55e;
+ --accent-cyan: #4a9eff;
+ --accent-orange: #f59e0b;
+ --accent-red: #ef4444;
+ --accent-yellow: #eab308;
+ --accent-amber: #d4a853;
+ --grid-line: rgba(74, 158, 255, 0.08);
+ --radar-cyan: #4a9eff;
+ --radar-bg: #0f1218;
}
body {
- font-family: 'Rajdhani', sans-serif;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -44,18 +47,18 @@ body {
z-index: 0;
}
-/* Scan line effect */
+/* Scan line effect - subtle */
.scanline {
position: fixed;
top: 0;
left: 0;
right: 0;
- height: 4px;
- background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
- animation: scan 4s linear infinite;
+ height: 2px;
+ background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
+ animation: scan 6s linear infinite;
pointer-events: none;
z-index: 1000;
- opacity: 0.5;
+ opacity: 0.3;
}
@keyframes scan {
@@ -73,20 +76,20 @@ body {
position: relative;
z-index: 10;
padding: 12px 20px;
- background: linear-gradient(180deg, rgba(0, 255, 136, 0.1) 0%, transparent 100%);
- border-bottom: 1px solid rgba(0, 255, 136, 0.3);
+ background: var(--bg-panel);
+ border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
- font-family: 'Orbitron', monospace;
- font-size: 24px;
- font-weight: 900;
- letter-spacing: 4px;
- color: var(--accent-green);
- text-shadow: 0 0 20px var(--accent-green), 0 0 40px var(--accent-green);
+ font-family: 'Inter', sans-serif;
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: 3px;
+ color: var(--text-primary);
+ text-transform: uppercase;
}
.logo span {
@@ -115,8 +118,8 @@ body {
width: 8px;
height: 8px;
border-radius: 50%;
- background: var(--accent-green);
- box-shadow: 0 0 10px var(--accent-green);
+ background: var(--accent-cyan);
+ box-shadow: 0 0 10px var(--accent-cyan);
animation: pulse 2s ease-in-out infinite;
}
@@ -144,8 +147,8 @@ body {
}
.stat-badge {
- background: rgba(0, 255, 136, 0.1);
- border: 1px solid rgba(0, 255, 136, 0.3);
+ background: rgba(74, 158, 255, 0.1);
+ border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
padding: 4px 10px;
font-family: 'JetBrains Mono', monospace;
@@ -153,7 +156,7 @@ body {
}
.stat-badge .value {
- color: var(--accent-green);
+ color: var(--accent-cyan);
font-weight: 600;
}
@@ -165,15 +168,15 @@ body {
.datetime {
font-family: 'Orbitron', monospace;
font-size: 12px;
- color: var(--accent-green);
+ color: var(--accent-cyan);
}
.back-link {
- color: var(--accent-green);
+ color: var(--accent-cyan);
text-decoration: none;
font-size: 11px;
padding: 4px 10px;
- border: 1px solid var(--accent-green);
+ border: 1px solid var(--accent-cyan);
border-radius: 4px;
}
@@ -192,7 +195,7 @@ body {
/* Panels */
.panel {
background: var(--bg-panel);
- border: 1px solid rgba(0, 255, 136, 0.2);
+ border: 1px solid rgba(74, 158, 255, 0.2);
overflow: hidden;
position: relative;
}
@@ -204,19 +207,19 @@ body {
left: 0;
right: 0;
height: 2px;
- background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
+ background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
}
.panel-header {
padding: 10px 15px;
- background: rgba(0, 255, 136, 0.05);
- border-bottom: 1px solid rgba(0, 255, 136, 0.1);
+ background: rgba(74, 158, 255, 0.05);
+ border-bottom: 1px solid rgba(74, 158, 255, 0.1);
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 2px;
text-transform: uppercase;
- color: var(--accent-green);
+ color: var(--accent-cyan);
display: flex;
justify-content: space-between;
align-items: center;
@@ -225,7 +228,7 @@ body {
.panel-indicator {
width: 6px;
height: 6px;
- background: var(--accent-green);
+ background: var(--accent-cyan);
border-radius: 50%;
animation: blink 1s ease-in-out infinite;
}
@@ -300,7 +303,7 @@ body {
grid-row: 1;
display: flex;
flex-direction: column;
- border-left: 1px solid rgba(0, 255, 136, 0.2);
+ border-left: 1px solid rgba(74, 158, 255, 0.2);
overflow: hidden;
}
@@ -310,13 +313,13 @@ body {
padding: 10px;
gap: 8px;
background: var(--bg-panel);
- border-bottom: 1px solid rgba(0, 255, 136, 0.2);
+ border-bottom: 1px solid rgba(74, 158, 255, 0.2);
}
.view-btn {
flex: 1;
padding: 10px;
- border: 1px solid rgba(0, 255, 136, 0.3);
+ border: 1px solid rgba(74, 158, 255, 0.3);
background: transparent;
color: var(--text-secondary);
font-family: 'Orbitron', monospace;
@@ -330,13 +333,13 @@ body {
}
.view-btn:hover {
- border-color: var(--accent-green);
- color: var(--accent-green);
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
}
.view-btn.active {
- background: var(--accent-green);
- border-color: var(--accent-green);
+ background: var(--accent-cyan);
+ border-color: var(--accent-cyan);
color: var(--bg-dark);
}
@@ -355,8 +358,8 @@ body {
font-family: 'Orbitron', monospace;
font-size: 20px;
font-weight: 700;
- color: var(--accent-green);
- text-shadow: 0 0 15px var(--accent-green);
+ color: var(--accent-cyan);
+ text-shadow: 0 0 15px var(--accent-cyan);
text-align: center;
margin-bottom: 12px;
}
@@ -371,7 +374,7 @@ body {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
padding: 8px;
- border-left: 2px solid var(--accent-green);
+ border-left: 2px solid var(--accent-cyan);
}
.telemetry-label {
@@ -404,7 +407,7 @@ body {
.aircraft-item {
background: rgba(0, 0, 0, 0.3);
- border: 1px solid rgba(0, 255, 136, 0.15);
+ border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 6px;
@@ -413,14 +416,14 @@ body {
}
.aircraft-item:hover {
- border-color: var(--accent-green);
- background: rgba(0, 255, 136, 0.05);
+ border-color: var(--accent-cyan);
+ background: rgba(74, 158, 255, 0.05);
}
.aircraft-item.selected {
- border-color: var(--accent-green);
- box-shadow: 0 0 15px rgba(0, 255, 136, 0.2);
- background: rgba(0, 255, 136, 0.1);
+ border-color: var(--accent-cyan);
+ box-shadow: 0 0 15px rgba(74, 158, 255, 0.2);
+ background: rgba(74, 158, 255, 0.1);
}
.aircraft-header {
@@ -434,14 +437,14 @@ body {
font-family: 'Orbitron', monospace;
font-size: 12px;
font-weight: 600;
- color: var(--accent-green);
+ color: var(--accent-cyan);
}
.aircraft-icao {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-secondary);
- background: rgba(0, 255, 136, 0.1);
+ background: rgba(74, 158, 255, 0.1);
padding: 2px 5px;
border-radius: 3px;
}
@@ -475,10 +478,11 @@ body {
grid-row: 2;
display: flex;
align-items: center;
- gap: 20px;
+ flex-wrap: wrap;
+ gap: 10px 20px;
padding: 10px 20px;
background: var(--bg-panel);
- border-top: 1px solid rgba(0, 255, 136, 0.3);
+ border-top: 1px solid rgba(74, 158, 255, 0.3);
}
.control-group {
@@ -497,15 +501,15 @@ body {
}
.control-group input[type="checkbox"] {
- accent-color: var(--accent-green);
+ accent-color: var(--accent-cyan);
}
.control-group select {
padding: 6px 10px;
background: rgba(0, 0, 0, 0.3);
- border: 1px solid rgba(0, 255, 136, 0.3);
+ border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
- color: var(--accent-green);
+ color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -514,9 +518,9 @@ body {
width: 80px;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.3);
- border: 1px solid rgba(0, 255, 136, 0.3);
+ border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
- color: var(--accent-green);
+ color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -531,9 +535,9 @@ body {
/* Start/stop button */
.start-btn {
padding: 8px 20px;
- border: 1px solid var(--accent-green);
- background: rgba(0, 255, 136, 0.1);
- color: var(--accent-green);
+ border: 1px solid var(--accent-cyan);
+ background: rgba(74, 158, 255, 0.1);
+ color: var(--accent-cyan);
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 600;
@@ -546,9 +550,9 @@ body {
}
.start-btn:hover {
- background: var(--accent-green);
+ background: var(--accent-cyan);
color: var(--bg-dark);
- box-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
+ box-shadow: 0 0 20px rgba(74, 158, 255, 0.3);
}
.start-btn.active {
@@ -564,10 +568,10 @@ body {
/* GPS button */
.gps-btn {
padding: 6px 10px;
- background: rgba(0, 255, 136, 0.2);
- border: 1px solid rgba(0, 255, 136, 0.3);
+ background: rgba(74, 158, 255, 0.2);
+ border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
- color: var(--accent-green);
+ color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
cursor: pointer;
@@ -578,10 +582,15 @@ body {
background: var(--bg-dark) !important;
}
+.leaflet-tile-pane,
+.leaflet-container .leaflet-tile-pane {
+ filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
+}
+
.leaflet-control-zoom a {
background: var(--bg-panel) !important;
- color: var(--accent-green) !important;
- border-color: rgba(0, 255, 136, 0.3) !important;
+ color: var(--accent-cyan) !important;
+ border-color: var(--border-color) !important;
}
.leaflet-control-attribution {
@@ -600,7 +609,7 @@ body {
}
::-webkit-scrollbar-thumb {
- background: var(--accent-green);
+ background: var(--accent-cyan);
border-radius: 3px;
}
@@ -632,7 +641,7 @@ body {
grid-column: 1;
grid-row: 2;
border-left: none;
- border-top: 1px solid rgba(0, 255, 136, 0.2);
+ border-top: 1px solid rgba(74, 158, 255, 0.2);
max-height: 300px;
}
@@ -640,4 +649,129 @@ body {
grid-row: 3;
flex-wrap: wrap;
}
-}
\ No newline at end of file
+}
+/* Airband Audio Controls */
+.airband-divider {
+ width: 1px;
+ height: 24px;
+ background: var(--border-color);
+ margin: 0 10px;
+}
+
+.airband-controls {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ flex-shrink: 0;
+}
+
+.airband-btn {
+ padding: 6px 12px;
+ background: rgba(74, 158, 255, 0.1);
+ border: 1px solid var(--accent-cyan);
+ color: var(--accent-cyan);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 600;
+ font-family: 'JetBrains Mono', monospace;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ transition: all 0.2s;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.airband-btn:hover {
+ background: rgba(74, 158, 255, 0.2);
+}
+
+.airband-btn.active {
+ background: rgba(34, 197, 94, 0.2);
+ border-color: var(--accent-green);
+ color: var(--accent-green);
+}
+
+.airband-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.airband-icon {
+ font-size: 10px;
+}
+
+.airband-status {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ padding: 0 8px;
+}
+
+#airbandSquelch {
+ accent-color: var(--accent-cyan);
+}
+
+/* Airband Audio Visualizer */
+.airband-visualizer {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 10px;
+ border-left: 1px solid var(--border-color);
+ margin-left: 5px;
+}
+
+.airband-visualizer .signal-meter {
+ width: 80px;
+}
+
+.airband-visualizer .meter-bar {
+ height: 10px;
+ background: linear-gradient(90deg,
+ var(--accent-green) 0%,
+ var(--accent-green) 60%,
+ var(--accent-orange) 60%,
+ var(--accent-orange) 80%,
+ var(--accent-red) 80%,
+ var(--accent-red) 100%
+ );
+ border-radius: 3px;
+ position: relative;
+ overflow: hidden;
+ opacity: 0.3;
+}
+
+.airband-visualizer .meter-fill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ background: linear-gradient(90deg,
+ var(--accent-green) 0%,
+ var(--accent-green) 60%,
+ var(--accent-orange) 60%,
+ var(--accent-orange) 80%,
+ var(--accent-red) 80%,
+ var(--accent-red) 100%
+ );
+ border-radius: 3px;
+ width: 0%;
+ transition: width 0.05s ease-out;
+}
+
+.airband-visualizer .meter-peak {
+ position: absolute;
+ top: 0;
+ height: 100%;
+ width: 2px;
+ background: #fff;
+ opacity: 0.8;
+ transition: left 0.05s ease-out;
+ left: 0%;
+}
+
+#airbandSpectrumCanvas {
+ border-radius: 3px;
+ background: rgba(0, 0, 0, 0.4);
+}
diff --git a/static/css/index.css b/static/css/index.css
index 40d9db7..f52a762 100644
--- a/static/css/index.css
+++ b/static/css/index.css
@@ -1,4 +1,4 @@
-@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@400;500;600;700&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
* {
box-sizing: border-box;
@@ -7,43 +7,65 @@
}
:root {
- --bg-primary: #000000;
- --bg-secondary: #0a0a0a;
- --bg-tertiary: #111111;
- --bg-card: #0d0d0d;
- --accent-cyan: #00d4ff;
- --accent-cyan-dim: #00d4ff40;
- --accent-green: #00ff88;
- --accent-red: #ff3366;
- --accent-orange: #ff8800;
- --text-primary: #ffffff;
- --text-secondary: #888888;
- --text-dim: #444444;
- --border-color: #1a1a1a;
- --border-glow: #00d4ff33;
+ /* Tactical dark palette */
+ --bg-primary: #0a0c10;
+ --bg-secondary: #0f1218;
+ --bg-tertiary: #151a23;
+ --bg-card: #121620;
+ --bg-elevated: #1a202c;
+
+ /* Accent colors - sophisticated blue/amber */
+ --accent-cyan: #4a9eff;
+ --accent-cyan-dim: rgba(74, 158, 255, 0.15);
+ --accent-green: #22c55e;
+ --accent-green-dim: rgba(34, 197, 94, 0.15);
+ --accent-red: #ef4444;
+ --accent-red-dim: rgba(239, 68, 68, 0.15);
+ --accent-orange: #f59e0b;
+ --accent-amber: #d4a853;
+ --accent-amber-dim: rgba(212, 168, 83, 0.15);
+
+ /* Text hierarchy */
+ --text-primary: #e8eaed;
+ --text-secondary: #9ca3af;
+ --text-dim: #4b5563;
+ --text-muted: #374151;
+
+ /* Borders */
+ --border-color: #1f2937;
+ --border-light: #374151;
+ --border-glow: rgba(74, 158, 255, 0.2);
+
+ /* Status colors */
+ --status-online: #22c55e;
+ --status-warning: #f59e0b;
+ --status-error: #ef4444;
+ --status-offline: #6b7280;
}
[data-theme="light"] {
- --bg-primary: #f5f5f5;
- --bg-secondary: #e8e8e8;
- --bg-tertiary: #dddddd;
+ --bg-primary: #f8fafc;
+ --bg-secondary: #f1f5f9;
+ --bg-tertiary: #e2e8f0;
--bg-card: #ffffff;
- --accent-cyan: #0088aa;
- --accent-cyan-dim: #0088aa40;
- --accent-green: #00aa55;
- --accent-red: #cc2244;
- --accent-orange: #cc6600;
- --text-primary: #111111;
- --text-secondary: #555555;
- --text-dim: #999999;
- --border-color: #cccccc;
- --border-glow: #0088aa33;
+ --bg-elevated: #f8fafc;
+ --accent-cyan: #2563eb;
+ --accent-cyan-dim: rgba(37, 99, 235, 0.1);
+ --accent-green: #16a34a;
+ --accent-red: #dc2626;
+ --accent-orange: #d97706;
+ --accent-amber: #b45309;
+ --text-primary: #0f172a;
+ --text-secondary: #475569;
+ --text-dim: #94a3b8;
+ --text-muted: #cbd5e1;
+ --border-color: #e2e8f0;
+ --border-light: #cbd5e1;
+ --border-glow: rgba(37, 99, 235, 0.15);
}
[data-theme="light"] body {
- background-image:
- radial-gradient(ellipse at top, #d0e8f0 0%, transparent 50%),
- radial-gradient(ellipse at bottom, #f0f0f0 0%, var(--bg-primary) 100%);
+ background: var(--bg-primary);
}
[data-theme="light"] .leaflet-tile-pane {
@@ -51,74 +73,207 @@
}
body {
- font-family: 'Rajdhani', 'Segoe UI', sans-serif;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
- background-image:
- radial-gradient(ellipse at top, #001a2c 0%, transparent 50%),
- radial-gradient(ellipse at bottom, #0a0a0a 0%, var(--bg-primary) 100%);
+ font-size: 14px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
}
.container {
- max-width: 1400px;
- margin: 0 auto;
- padding: 20px;
+ max-width: 100%;
+ margin: 0;
+ padding: 0;
}
header {
- background: linear-gradient(180deg, var(--bg-secondary) 0%, transparent 100%);
- padding: 30px 20px;
+ background: var(--bg-secondary);
+ padding: 12px 20px;
+ display: block;
text-align: center;
border-bottom: 1px solid var(--border-color);
- margin-bottom: 25px;
position: relative;
}
-header::after {
+header::before {
content: '';
position: absolute;
- bottom: -1px;
- left: 50%;
- transform: translateX(-50%);
- width: 200px;
- height: 1px;
- background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: linear-gradient(90deg, var(--accent-cyan) 0%, var(--accent-amber) 50%, var(--accent-cyan) 100%);
+ opacity: 0.8;
+}
+
+header::after {
+ display: none;
}
header h1 {
color: var(--text-primary);
- font-size: 2.5em;
- font-weight: 700;
- letter-spacing: 8px;
+ font-size: 1.1rem;
+ font-weight: 600;
+ letter-spacing: 0.15em;
text-transform: uppercase;
- margin-bottom: 8px;
- text-shadow: 0 0 30px var(--accent-cyan-dim);
+ margin: 0;
+ display: inline;
+ vertical-align: middle;
+}
+
+.logo {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: 8px;
+}
+
+.logo svg {
+ width: 36px;
+ height: 36px;
+ filter: drop-shadow(0 0 8px var(--accent-cyan-dim));
+ transition: filter 0.3s ease;
+}
+
+.logo:hover svg {
+ filter: drop-shadow(0 0 12px var(--accent-cyan));
+}
+
+/* Mode Navigation Bar */
+.mode-nav {
+ background: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-color);
+ padding: 0 20px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 44px;
+}
+
+.mode-nav-group {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.mode-nav-label {
+ font-size: 9px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-right: 8px;
+ font-weight: 500;
+}
+
+.mode-nav-divider {
+ width: 1px;
+ height: 24px;
+ background: var(--border-color);
+ margin: 0 12px;
+}
+
+.mode-nav-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: var(--text-secondary);
+ font-family: 'Inter', sans-serif;
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.mode-nav-btn .nav-icon {
+ font-size: 14px;
+}
+
+.mode-nav-btn .nav-label {
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.mode-nav-btn:hover {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border-color: var(--border-color);
+}
+
+.mode-nav-btn.active {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
+ border-color: var(--accent-cyan);
+}
+
+.mode-nav-btn.active .nav-icon {
+ filter: brightness(0);
+}
+
+.mode-nav-actions {
+ margin-left: auto;
+}
+
+.nav-action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--accent-cyan);
+ border-radius: 4px;
+ color: var(--accent-cyan);
+ font-family: 'Inter', sans-serif;
+ font-size: 11px;
+ font-weight: 500;
+ text-decoration: none;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.nav-action-btn .nav-icon {
+ font-size: 12px;
+}
+
+.nav-action-btn .nav-label {
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.nav-action-btn:hover {
+ background: var(--accent-cyan);
+ color: var(--bg-primary);
}
.version-badge {
- font-size: 0.35em;
- font-weight: 400;
- letter-spacing: 1px;
- color: var(--text-muted);
- vertical-align: middle;
- margin-left: 5px;
- opacity: 0.7;
+ font-size: 0.6rem;
+ font-weight: 500;
+ font-family: 'JetBrains Mono', monospace;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary);
+ background: var(--bg-tertiary);
+ padding: 2px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
}
header p {
color: var(--text-secondary);
- font-size: 14px;
- letter-spacing: 3px;
+ font-size: 11px;
+ letter-spacing: 0.1em;
text-transform: uppercase;
+ margin: 4px 0 8px 0;
}
-/* New header stat badges */
+/* Header stat badges */
.header-stats {
display: flex;
justify-content: center;
- gap: 15px;
- margin-top: 15px;
+ gap: 8px;
flex-wrap: wrap;
}
@@ -126,8 +281,8 @@ header p {
display: flex;
align-items: center;
gap: 8px;
- padding: 8px 16px;
- background: linear-gradient(135deg, rgba(0, 212, 255, 0.1), rgba(0, 0, 0, 0.3));
+ padding: 6px 12px;
+ background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
@@ -135,30 +290,38 @@ header p {
}
.stat-badge:hover {
- border-color: var(--accent-cyan);
- box-shadow: 0 0 15px var(--accent-cyan-dim);
+ border-color: var(--border-light);
+ background: var(--bg-elevated);
+}
+
+.stat-badge .badge-icon {
+ font-size: 14px;
+ opacity: 0.7;
}
.stat-badge .badge-value {
- font-size: 18px;
- font-weight: 700;
+ font-size: 14px;
+ font-weight: 600;
color: var(--accent-cyan);
- text-shadow: 0 0 10px var(--accent-cyan-dim);
+}
+
+.stat-badge .badge-label {
+ font-size: 10px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
}
.stat-badge .badge-value.highlight {
color: var(--accent-green);
- text-shadow: 0 0 10px rgba(0, 255, 136, 0.4);
}
.stat-badge .badge-value.warning {
color: var(--accent-orange);
- text-shadow: 0 0 10px rgba(255, 136, 0, 0.4);
}
.stat-badge .badge-value.alert {
color: var(--accent-red);
- text-shadow: 0 0 10px rgba(255, 51, 102, 0.4);
}
.stat-badge .badge-label {
@@ -186,8 +349,9 @@ header p {
/* UTC Clock in header */
.header-clock {
position: absolute;
- top: 20px;
+ top: 50%;
left: 20px;
+ transform: translateY(-50%);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text-secondary);
@@ -234,7 +398,8 @@ header p {
.help-btn {
position: absolute;
- top: 20px;
+ top: 50%;
+ transform: translateY(-50%);
right: 20px;
width: 32px;
height: 32px;
@@ -255,13 +420,18 @@ header p {
.help-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
- box-shadow: 0 0 15px var(--accent-cyan-dim);
+ background: var(--bg-secondary);
+}
+
+#depsBtn {
+ right: 60px;
}
.theme-toggle {
position: absolute;
- top: 20px;
- right: 60px;
+ top: 50%;
+ transform: translateY(-50%);
+ right: 100px;
width: 32px;
height: 32px;
border-radius: 50%;
@@ -280,7 +450,7 @@ header p {
.theme-toggle:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
- box-shadow: 0 0 15px var(--accent-cyan-dim);
+ background: var(--bg-secondary);
}
.theme-toggle .icon-sun,
@@ -443,7 +613,8 @@ header p {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
- transition: all 0.2s;
+ transition: all 0.15s ease;
+ position: relative;
}
.help-tab:not(:last-child) {
@@ -451,12 +622,23 @@ header p {
}
.help-tab:hover {
- background: var(--bg-secondary);
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
}
.help-tab.active {
+ background: var(--bg-tertiary);
+ color: var(--accent-cyan);
+}
+
+.help-tab.active::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
background: var(--accent-cyan);
- color: var(--bg-primary);
}
.help-section {
@@ -467,31 +649,12 @@ header p {
display: block;
}
-.logo {
- margin-bottom: 15px;
- animation: logo-pulse 3s ease-in-out infinite;
-}
-
-.logo svg {
- filter: drop-shadow(0 0 10px var(--accent-cyan-dim));
-}
-
-@keyframes logo-pulse {
-
- 0%,
- 100% {
- filter: drop-shadow(0 0 5px var(--accent-cyan-dim));
- }
-
- 50% {
- filter: drop-shadow(0 0 20px var(--accent-cyan));
- }
-}
-
.main-content {
display: grid;
- grid-template-columns: 340px 1fr;
- gap: 25px;
+ grid-template-columns: 320px 1fr;
+ gap: 0;
+ height: calc(100vh - 96px);
+ overflow: hidden;
}
@media (max-width: 900px) {
@@ -501,71 +664,78 @@ header p {
}
.sidebar {
- background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-primary) 100%);
- border: 1px solid var(--border-color);
- padding: 20px;
- position: relative;
- border-radius: 8px;
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border-color);
+ padding: 12px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
}
.sidebar::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 2px;
- background: linear-gradient(90deg, var(--accent-cyan), transparent);
- border-radius: 8px 8px 0 0;
+ display: none;
}
.section {
- margin-bottom: 20px;
- background: linear-gradient(135deg, rgba(0, 212, 255, 0.03), rgba(0, 0, 0, 0.2));
+ background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
+ overflow: hidden;
padding: 12px;
position: relative;
}
.section h3 {
- color: var(--accent-cyan);
- margin-bottom: 12px;
- padding-bottom: 8px;
+ color: var(--text-primary);
+ margin: -12px -12px 12px -12px;
+ padding: 10px 12px;
+ background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
- letter-spacing: 2px;
+ letter-spacing: 0.1em;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
- font-family: 'Orbitron', 'Rajdhani', sans-serif;
}
.section h3::before {
content: '';
- width: 6px;
- height: 6px;
+ width: 3px;
+ height: 12px;
background: var(--accent-cyan);
- border-radius: 50%;
- box-shadow: 0 0 8px var(--accent-cyan);
+ border-radius: 2px;
flex-shrink: 0;
}
.section h3::after {
- content: '▼';
- font-size: 8px;
- color: var(--text-dim);
- transition: transform 0.2s ease;
+ content: '▾';
+ font-size: 10px;
+ color: var(--text-secondary);
+ transition: transform 0.2s ease, color 0.2s ease;
margin-left: auto;
- font-family: sans-serif;
+ padding: 2px 6px;
+ background: var(--bg-primary);
+ border-radius: 3px;
+}
+
+.section.collapsed h3 {
+ border-bottom: none;
+ margin-bottom: 0;
+ padding-bottom: 10px;
}
.section.collapsed h3::after {
- transform: rotate(-90deg);
+ content: '▸';
+ color: var(--accent-cyan);
+}
+
+.section.collapsed {
+ padding-bottom: 0;
}
.section.collapsed>*:not(h3) {
@@ -573,16 +743,19 @@ header p {
}
.section h3:hover {
- color: var(--text-primary);
+ background: var(--bg-elevated);
}
.section h3:hover::before {
- background: var(--accent-green);
- box-shadow: 0 0 12px var(--accent-green);
+ background: var(--accent-amber);
}
.form-group {
- margin-bottom: 12px;
+ margin-bottom: 10px;
+}
+
+.form-group:last-child {
+ margin-bottom: 0;
}
.form-group label {
@@ -590,50 +763,70 @@ header p {
margin-bottom: 4px;
color: var(--text-secondary);
font-size: 10px;
+ font-weight: 500;
text-transform: uppercase;
- letter-spacing: 1px;
+ letter-spacing: 0.05em;
+}
+
+.form-group label.inline-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ margin-bottom: 0;
+ text-transform: none;
+ font-size: 11px;
+}
+
+.form-group label.inline-checkbox input[type="checkbox"] {
+ width: auto;
+ margin: 0;
+ accent-color: var(--accent-cyan);
}
.form-group input,
.form-group select {
width: 100%;
- padding: 10px 12px;
+ padding: 8px 10px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
+ border-radius: 4px;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
- font-size: 13px;
- transition: all 0.2s ease;
+ font-size: 12px;
+ transition: all 0.15s ease;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-cyan);
- box-shadow: 0 0 15px var(--accent-cyan-dim), inset 0 0 15px var(--accent-cyan-dim);
+ box-shadow: 0 0 0 2px var(--accent-cyan-dim);
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
- gap: 12px;
+ gap: 8px;
}
.checkbox-group label {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 6px;
color: var(--text-secondary);
- font-size: 12px;
+ font-size: 11px;
cursor: pointer;
- padding: 8px 12px;
+ padding: 6px 10px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
- transition: all 0.2s ease;
+ border-radius: 4px;
+ transition: all 0.15s ease;
}
.checkbox-group label:hover {
- border-color: var(--accent-cyan);
+ border-color: var(--border-light);
+ background: var(--bg-secondary);
}
.checkbox-group input[type="checkbox"] {
@@ -648,113 +841,99 @@ header p {
}
.preset-btn {
- padding: 8px 14px;
+ padding: 6px 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
+ font-size: 10px;
text-transform: uppercase;
- letter-spacing: 1px;
- transition: all 0.2s ease;
- border-radius: 3px;
+ letter-spacing: 0.05em;
+ transition: all 0.15s ease;
+ border-radius: 4px;
}
.preset-btn:hover {
- background: var(--accent-cyan);
- color: var(--bg-primary);
+ background: var(--bg-secondary);
+ color: var(--accent-cyan);
border-color: var(--accent-cyan);
- box-shadow: 0 0 20px var(--accent-cyan-dim);
}
.run-btn {
width: 100%;
- padding: 14px;
- background: transparent;
- border: 2px solid var(--accent-green);
- color: var(--accent-green);
- font-family: 'Rajdhani', sans-serif;
- font-size: 13px;
- font-weight: 700;
+ padding: 12px;
+ background: var(--accent-green);
+ border: none;
+ color: #fff;
+ font-family: 'Inter', sans-serif;
+ font-size: 12px;
+ font-weight: 600;
text-transform: uppercase;
- letter-spacing: 3px;
+ letter-spacing: 0.1em;
cursor: pointer;
- transition: all 0.3s ease;
- margin-top: 12px;
- position: relative;
- overflow: hidden;
+ transition: all 0.2s ease;
+ margin-top: 8px;
border-radius: 4px;
}
.run-btn::before {
- content: '';
- position: absolute;
- top: 0;
- left: -100%;
- width: 100%;
- height: 100%;
- background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
- opacity: 0.3;
- transition: left 0.5s ease;
+ display: none;
}
.run-btn:hover {
- background: var(--accent-green);
- color: var(--bg-primary);
- box-shadow: 0 0 30px rgba(0, 255, 136, 0.4);
+ background: #1db954;
+ box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
+ transform: translateY(-1px);
}
-.run-btn:hover::before {
- left: 100%;
+.run-btn:active {
+ transform: translateY(0);
+}
+
+.run-btn.active,
+.stop-btn {
+ background: var(--accent-red);
+ color: #fff;
}
.stop-btn {
width: 100%;
- padding: 16px;
- background: transparent;
- border: 2px solid var(--accent-red);
- color: var(--accent-red);
- font-family: 'Rajdhani', sans-serif;
- font-size: 14px;
- font-weight: 700;
+ padding: 12px;
+ background: var(--accent-red);
+ border: none;
+ color: #fff;
+ font-family: 'Inter', sans-serif;
+ font-size: 12px;
+ font-weight: 600;
text-transform: uppercase;
- letter-spacing: 4px;
+ letter-spacing: 0.1em;
cursor: pointer;
- transition: all 0.3s ease;
- margin-top: 15px;
+ transition: all 0.2s ease;
+ margin-top: 8px;
+ border-radius: 4px;
}
.stop-btn:hover {
- background: var(--accent-red);
- color: var(--bg-primary);
- box-shadow: 0 0 30px rgba(255, 51, 102, 0.4);
+ background: #dc2626;
+ box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.output-panel {
- background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-primary) 100%);
- border: 1px solid var(--border-color);
+ background: var(--bg-primary);
display: flex;
flex-direction: column;
position: relative;
- border-radius: 8px;
overflow: hidden;
}
.output-panel::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 2px;
- background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
- border-radius: 8px 8px 0 0;
+ display: none;
}
.output-header {
- padding: 15px 20px;
- background: linear-gradient(135deg, rgba(0, 212, 255, 0.05), rgba(0, 0, 0, 0.3));
+ padding: 10px 16px;
+ background: var(--bg-secondary);
display: flex;
justify-content: space-between;
align-items: center;
@@ -763,28 +942,26 @@ header p {
.output-header h3 {
color: var(--text-primary);
- font-size: 12px;
+ font-size: 11px;
font-weight: 600;
text-transform: uppercase;
- letter-spacing: 3px;
- font-family: 'Orbitron', 'Rajdhani', sans-serif;
+ letter-spacing: 0.1em;
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
}
.output-header h3::before {
content: '';
- width: 8px;
- height: 8px;
+ width: 6px;
+ height: 6px;
background: var(--accent-cyan);
border-radius: 50%;
- box-shadow: 0 0 10px var(--accent-cyan);
}
.stats {
display: flex;
- gap: 12px;
+ gap: 8px;
font-size: 10px;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
@@ -795,13 +972,13 @@ header p {
align-items: center;
gap: 4px;
padding: 4px 8px;
- background: var(--bg-primary);
+ background: var(--bg-tertiary);
border: 1px solid var(--border-color);
- border-radius: 3px;
+ border-radius: 4px;
}
.stats>div:hover {
- border-color: var(--accent-cyan);
+ border-color: var(--border-light);
}
.stats span {
@@ -811,46 +988,44 @@ header p {
.output-content {
flex: 1;
- padding: 15px;
+ padding: 12px;
overflow-y: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
background: var(--bg-primary);
- margin: 15px;
- border: 1px solid var(--border-color);
- min-height: 500px;
- max-height: 600px;
}
.output-content::-webkit-scrollbar {
- width: 6px;
+ width: 8px;
}
.output-content::-webkit-scrollbar-track {
- background: var(--bg-primary);
+ background: var(--bg-secondary);
}
.output-content::-webkit-scrollbar-thumb {
- background: var(--border-color);
+ background: var(--border-light);
+ border-radius: 4px;
}
.output-content::-webkit-scrollbar-thumb:hover {
- background: var(--accent-cyan);
+ background: var(--text-dim);
}
.message {
- padding: 15px;
- margin-bottom: 10px;
+ padding: 12px;
+ margin-bottom: 8px;
border: 1px solid var(--border-color);
border-left: 3px solid var(--accent-cyan);
background: var(--bg-secondary);
+ border-radius: 4px;
position: relative;
- transition: all 0.2s ease;
+ transition: all 0.15s ease;
}
.message:hover {
- border-left-color: var(--accent-cyan);
- box-shadow: 0 0 20px var(--accent-cyan-dim);
+ background: var(--bg-tertiary);
+ border-color: var(--border-light);
}
.message.pocsag {
@@ -858,7 +1033,7 @@ header p {
}
.message.flex {
- border-left-color: var(--accent-orange);
+ border-left-color: var(--accent-amber);
}
.message .header {
@@ -1081,7 +1256,6 @@ header p {
.signal-bar.active {
background: var(--accent-cyan);
- box-shadow: 0 0 8px var(--accent-cyan);
}
.waterfall-container {
@@ -1098,7 +1272,6 @@ header p {
}
#waterfallCanvas.active {
- box-shadow: 0 0 15px var(--accent-cyan-dim);
border-color: var(--accent-cyan);
}
@@ -1118,7 +1291,7 @@ header p {
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s ease;
- font-family: 'Rajdhani', sans-serif;
+ font-family: 'Inter', sans-serif;
}
.control-btn:hover {
@@ -1166,7 +1339,9 @@ header p {
font-family: 'JetBrains Mono', monospace;
}
-#signalGraph {
+#signalGraph,
+#btSignalGraph,
+#adsbStatsChart {
width: 100%;
height: 80px;
background: var(--bg-primary);
@@ -1231,6 +1406,103 @@ header p {
background: var(--accent-orange);
}
+/* Spectrum Waterfall */
+.spectrum-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.spectrum-header h5 {
+ margin: 0;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.spectrum-info {
+ display: flex;
+ gap: 15px;
+ font-size: 11px;
+}
+
+.spectrum-info span:last-child {
+ font-weight: bold;
+}
+
+.waterfall-container {
+ position: relative;
+ background: #000;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+#waterfallCanvas {
+ width: 100%;
+ height: 200px;
+ display: block;
+}
+
+.waterfall-scale {
+ display: flex;
+ justify-content: space-between;
+ padding: 4px 8px;
+ font-size: 9px;
+ color: var(--text-dim);
+ background: rgba(0, 0, 0, 0.5);
+}
+
+.spectrum-legend {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ margin-top: 8px;
+ font-size: 10px;
+ color: var(--text-dim);
+}
+
+.spectrum-gradient {
+ width: 150px;
+ height: 12px;
+ background: linear-gradient(to right,
+ #000080, #0000ff, #00ffff, #00ff00, #ffff00, #ff0000, #ffffff);
+ border-radius: 2px;
+}
+
+#spectrumLineChart {
+ width: 100%;
+ height: 100px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 3px;
+}
+
+/* Audio Receiver Controls */
+.status-line {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ padding: 4px 0;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.status-line:last-of-type {
+ border-bottom: none;
+}
+
+#audioStartBtn.active {
+ background: var(--accent-green);
+ border-color: var(--accent-green);
+ color: var(--bg-primary);
+}
+
+#waterfallCanvas {
+ cursor: crosshair;
+}
+
/* Channel Recommendation */
.channel-recommendation {
background: var(--bg-card);
@@ -1277,70 +1549,6 @@ header p {
font-style: italic;
}
-/* Mode tabs - grouped layout */
-.mode-tabs-container {
- margin-bottom: 15px;
-}
-
-.tab-group {
- margin-bottom: 8px;
-}
-
-.tab-group-label {
- font-size: 9px;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 2px;
- margin-bottom: 4px;
- padding-left: 4px;
- font-family: 'Rajdhani', sans-serif;
-}
-
-.mode-tabs {
- display: flex;
- gap: 0;
- border: 1px solid var(--border-color);
- border-radius: 4px;
- overflow: hidden;
-}
-
-.mode-tab {
- flex: 1;
- padding: 10px 8px;
- background: var(--bg-primary);
- border: none;
- color: var(--text-secondary);
- cursor: pointer;
- font-family: 'Rajdhani', sans-serif;
- font-size: 10px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 1px;
- transition: all 0.2s ease;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
-}
-
-.mode-tab .tab-icon {
- font-size: 16px;
-}
-
-.mode-tab:not(:last-child) {
- border-right: 1px solid var(--border-color);
-}
-
-.mode-tab:hover {
- background: var(--bg-secondary);
- color: var(--text-primary);
-}
-
-.mode-tab.active {
- background: var(--accent-cyan);
- color: var(--bg-primary);
-}
-
.mode-content {
display: none;
}
@@ -1413,8 +1621,9 @@ header p {
font-family: 'JetBrains Mono', monospace;
}
-.leaflet-tile-pane {
- filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2);
+.leaflet-tile-pane,
+.leaflet-container .leaflet-tile-pane {
+ filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom {
@@ -1559,7 +1768,7 @@ header p {
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
- font-family: 'Rajdhani', sans-serif;
+ font-family: 'Inter', sans-serif;
font-size: 11px;
text-transform: uppercase;
transition: all 0.2s ease;
@@ -2631,10 +2840,11 @@ body::before {
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
- z-index: 9999;
+ z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
+ pointer-events: auto;
}
.disclaimer-modal {
@@ -2644,6 +2854,9 @@ body::before {
padding: 30px;
text-align: center;
box-shadow: 0 0 50px rgba(0, 212, 255, 0.3);
+ pointer-events: auto;
+ position: relative;
+ z-index: 100000;
}
.disclaimer-modal h2 {
@@ -2683,13 +2896,16 @@ body::before {
color: #000;
border: none;
padding: 12px 40px;
- font-family: 'Rajdhani', sans-serif;
+ font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 600;
letter-spacing: 2px;
cursor: pointer;
margin-top: 20px;
transition: all 0.3s ease;
+ pointer-events: auto;
+ position: relative;
+ z-index: 100001;
}
.disclaimer-modal .accept-btn:hover {
@@ -2954,4 +3170,126 @@ body::before {
width: 50px;
height: 50px;
font-size: 16px;
-}
\ No newline at end of file
+}
+/* Audio Visualizer */
+.audio-visualizer {
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 10px;
+}
+
+.signal-meter {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
+.signal-meter label {
+ font-size: 10px;
+ color: var(--text-secondary);
+ width: 40px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.meter-bar {
+ flex: 1;
+ height: 12px;
+ background: linear-gradient(90deg,
+ var(--accent-green) 0%,
+ var(--accent-green) 60%,
+ var(--accent-orange) 60%,
+ var(--accent-orange) 80%,
+ var(--accent-red) 80%,
+ var(--accent-red) 100%
+ );
+ border-radius: 3px;
+ position: relative;
+ overflow: hidden;
+ opacity: 0.3;
+}
+
+.meter-fill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ background: linear-gradient(90deg,
+ var(--accent-green) 0%,
+ var(--accent-green) 60%,
+ var(--accent-orange) 60%,
+ var(--accent-orange) 80%,
+ var(--accent-red) 80%,
+ var(--accent-red) 100%
+ );
+ border-radius: 3px;
+ width: 0%;
+ transition: width 0.05s ease-out;
+}
+
+.meter-peak {
+ position: absolute;
+ top: 0;
+ height: 100%;
+ width: 2px;
+ background: #fff;
+ opacity: 0.8;
+ transition: left 0.05s ease-out;
+ left: 0%;
+}
+
+.meter-value {
+ font-size: 10px;
+ font-family: 'JetBrains Mono', monospace;
+ color: var(--text-secondary);
+ width: 50px;
+ text-align: right;
+}
+
+#audioSpectrumCanvas {
+ width: 100%;
+ height: 60px;
+ border-radius: 4px;
+ background: rgba(0, 0, 0, 0.4);
+}
+
+/* Airband visualizer in ADS-B dashboard */
+.airband-visualizer {
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 8px;
+ margin-top: 10px;
+}
+
+.airband-visualizer .signal-meter {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.airband-visualizer .signal-meter label {
+ font-size: 9px;
+ color: var(--text-secondary);
+ width: 35px;
+}
+
+.airband-visualizer .meter-bar {
+ flex: 1;
+ height: 10px;
+}
+
+.airband-visualizer .meter-value {
+ font-size: 9px;
+ width: 45px;
+}
+
+.airband-visualizer canvas {
+ width: 100%;
+ height: 50px;
+ border-radius: 3px;
+ background: rgba(0, 0, 0, 0.4);
+}
diff --git a/static/css/satellite_dashboard.css b/static/css/satellite_dashboard.css
index be6cc7d..fcaa3a6 100644
--- a/static/css/satellite_dashboard.css
+++ b/static/css/satellite_dashboard.css
@@ -5,22 +5,25 @@
}
:root {
- --bg-dark: #0a0a0f;
- --bg-panel: #0d1117;
- --bg-card: #161b22;
- --border-glow: #00d4ff;
- --text-primary: #e6edf3;
- --text-secondary: #8b949e;
- --accent-cyan: #00d4ff;
- --accent-green: #00ff88;
- --accent-orange: #ff9500;
- --accent-red: #ff4444;
+ --bg-dark: #0a0c10;
+ --bg-panel: #0f1218;
+ --bg-card: #151a23;
+ --border-color: #1f2937;
+ --border-glow: #4a9eff;
+ --text-primary: #e8eaed;
+ --text-secondary: #9ca3af;
+ --text-dim: #4b5563;
+ --accent-cyan: #4a9eff;
+ --accent-green: #22c55e;
+ --accent-orange: #f59e0b;
+ --accent-red: #ef4444;
--accent-purple: #a855f7;
- --grid-line: rgba(0, 212, 255, 0.1);
+ --accent-amber: #d4a853;
+ --grid-line: rgba(74, 158, 255, 0.08);
}
body {
- font-family: 'Rajdhani', sans-serif;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -82,28 +85,28 @@ body {
position: relative;
z-index: 10;
padding: 12px 20px;
- background: linear-gradient(180deg, rgba(0, 212, 255, 0.1) 0%, transparent 100%);
- border-bottom: 1px solid rgba(0, 212, 255, 0.3);
+ background: var(--bg-panel);
+ border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
- font-family: 'Orbitron', monospace;
- font-size: 24px;
- font-weight: 900;
- letter-spacing: 4px;
- color: var(--accent-cyan);
- text-shadow: 0 0 20px var(--accent-cyan), 0 0 40px var(--accent-cyan);
+ font-family: 'Inter', sans-serif;
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: 3px;
+ color: var(--text-primary);
+ text-transform: uppercase;
}
.logo span {
color: var(--text-secondary);
font-weight: 400;
- font-size: 14px;
+ font-size: 12px;
margin-left: 15px;
- letter-spacing: 2px;
+ letter-spacing: 1px;
}
/* Stats badges in header */
@@ -113,7 +116,7 @@ body {
}
.stat-badge {
- background: rgba(0, 212, 255, 0.1);
+ background: var(--bg-card);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
padding: 4px 10px;
@@ -600,10 +603,15 @@ body {
background: var(--bg-dark) !important;
}
+.leaflet-tile-pane,
+.leaflet-container .leaflet-tile-pane {
+ filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
+}
+
.leaflet-control-zoom a {
background: var(--bg-panel) !important;
color: var(--accent-cyan) !important;
- border-color: rgba(0, 212, 255, 0.3) !important;
+ border-color: var(--border-color) !important;
}
.leaflet-control-attribution {
diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html
index 807562c..ee2c3f7 100644
--- a/templates/adsb_dashboard.html
+++ b/templates/adsb_dashboard.html
@@ -4,7 +4,7 @@
AIRCRAFT RADAR // INTERCEPT
-
+
@@ -179,6 +179,50 @@
+
+
+ AIRBAND:
+
+
+
+
+ SDR:
+
+
+
+ SQ:
+
+
+
+
+ OFF
+
+
+
+
@@ -1117,8 +1161,8 @@
maxZoom: 15
});
- L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
- attribution: '©OpenStreetMap, ©CartoDB'
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap contributors'
}).addTo(radarMap);
if (navigator.geolocation) {
@@ -1554,6 +1598,318 @@
scheduleUIUpdate();
}
}
+
+ // ============================================
+ // AIRBAND AUDIO
+ // ============================================
+ let isAirbandPlaying = false;
+
+ // Web Audio API for airband visualization
+ let airbandAudioContext = null;
+ let airbandAnalyser = null;
+ let airbandSource = null;
+ let airbandVisualizerId = null;
+ let airbandPeakLevel = 0;
+
+ function initAirbandVisualizer() {
+ const audioPlayer = document.getElementById('airbandPlayer');
+
+ if (!airbandAudioContext) {
+ airbandAudioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+
+ if (airbandAudioContext.state === 'suspended') {
+ airbandAudioContext.resume();
+ }
+
+ if (!airbandSource) {
+ try {
+ airbandSource = airbandAudioContext.createMediaElementSource(audioPlayer);
+ airbandAnalyser = airbandAudioContext.createAnalyser();
+ airbandAnalyser.fftSize = 128;
+ airbandAnalyser.smoothingTimeConstant = 0.7;
+
+ airbandSource.connect(airbandAnalyser);
+ airbandAnalyser.connect(airbandAudioContext.destination);
+ } catch (e) {
+ console.warn('Could not create airband audio source:', e);
+ return;
+ }
+ }
+
+ document.getElementById('airbandVisualizerContainer').style.display = 'flex';
+ drawAirbandVisualizer();
+ }
+
+ function drawAirbandVisualizer() {
+ if (!airbandAnalyser) return;
+
+ const canvas = document.getElementById('airbandSpectrumCanvas');
+ const ctx = canvas.getContext('2d');
+ const bufferLength = airbandAnalyser.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+
+ function draw() {
+ airbandVisualizerId = requestAnimationFrame(draw);
+ airbandAnalyser.getByteFrequencyData(dataArray);
+
+ // Signal meter
+ let sum = 0;
+ for (let i = 0; i < bufferLength; i++) sum += dataArray[i];
+ const average = sum / bufferLength;
+ const levelPercent = (average / 255) * 100;
+
+ if (levelPercent > airbandPeakLevel) {
+ airbandPeakLevel = levelPercent;
+ } else {
+ airbandPeakLevel *= 0.95;
+ }
+
+ const meterFill = document.getElementById('airbandSignalMeter');
+ const meterPeak = document.getElementById('airbandSignalPeak');
+ if (meterFill) meterFill.style.width = levelPercent + '%';
+ if (meterPeak) meterPeak.style.left = Math.min(airbandPeakLevel, 100) + '%';
+
+ // Draw spectrum
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ const barWidth = canvas.width / bufferLength * 2;
+ let x = 0;
+
+ for (let i = 0; i < bufferLength; i++) {
+ const barHeight = (dataArray[i] / 255) * canvas.height;
+ const hue = 200 - (i / bufferLength) * 60;
+ ctx.fillStyle = `hsl(${hue}, 80%, ${40 + (dataArray[i] / 255) * 30}%)`;
+ ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
+ x += barWidth;
+ }
+ }
+ draw();
+ }
+
+ function stopAirbandVisualizer() {
+ if (airbandVisualizerId) {
+ cancelAnimationFrame(airbandVisualizerId);
+ airbandVisualizerId = null;
+ }
+
+ const meterFill = document.getElementById('airbandSignalMeter');
+ const meterPeak = document.getElementById('airbandSignalPeak');
+ if (meterFill) meterFill.style.width = '0%';
+ if (meterPeak) meterPeak.style.left = '0%';
+ airbandPeakLevel = 0;
+
+ const container = document.getElementById('airbandVisualizerContainer');
+ if (container) container.style.display = 'none';
+ }
+
+ function initAirband() {
+ // Populate device selector
+ fetch('/devices')
+ .then(r => r.json())
+ .then(devices => {
+ const select = document.getElementById('airbandDeviceSelect');
+ select.innerHTML = '';
+ if (devices.length === 0) {
+ select.innerHTML = '';
+ } else {
+ devices.forEach((dev, i) => {
+ const opt = document.createElement('option');
+ opt.value = dev.index || i;
+ opt.textContent = `Dev ${dev.index || i}`;
+ select.appendChild(opt);
+ });
+ }
+ })
+ .catch(() => {});
+
+ // Check if audio tools are available
+ fetch('/listening/tools')
+ .then(r => r.json())
+ .then(data => {
+ const missingTools = [];
+ if (!data.rtl_fm) missingTools.push('rtl_fm');
+ if (!data.sox) missingTools.push('sox (audio player)');
+
+ if (missingTools.length > 0) {
+ document.getElementById('airbandBtn').disabled = true;
+ document.getElementById('airbandBtn').style.opacity = '0.5';
+ document.getElementById('airbandStatus').textContent = 'UNAVAILABLE';
+ document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
+
+ // Show warning banner
+ showAirbandWarning(missingTools);
+ }
+ })
+ .catch(() => {
+ // Endpoint not available, disable airband
+ document.getElementById('airbandBtn').disabled = true;
+ document.getElementById('airbandBtn').style.opacity = '0.5';
+ document.getElementById('airbandStatus').textContent = 'UNAVAILABLE';
+ document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
+ });
+ }
+
+ function showAirbandWarning(missingTools) {
+ const warning = document.createElement('div');
+ warning.id = 'airbandWarning';
+ warning.style.cssText = `
+ position: fixed;
+ bottom: 70px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(239, 68, 68, 0.95);
+ color: white;
+ padding: 12px 20px;
+ border-radius: 8px;
+ font-size: 12px;
+ z-index: 10000;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.5);
+ max-width: 400px;
+ text-align: center;
+ `;
+
+ const toolList = missingTools.join(', ');
+ warning.innerHTML = `
+ ⚠️ Airband Listen Unavailable
+ Missing required tools: ${toolList}
+
+ Install with: sudo apt install rtl-sdr sox (Debian) or brew install librtlsdr sox (macOS)
+
+
+ `;
+ document.body.appendChild(warning);
+
+ // Auto-dismiss after 15 seconds
+ setTimeout(() => {
+ if (warning.parentElement) {
+ warning.style.opacity = '0';
+ warning.style.transition = 'opacity 0.3s';
+ setTimeout(() => warning.remove(), 300);
+ }
+ }, 15000);
+ }
+
+ function updateAirbandFreq() {
+ const select = document.getElementById('airbandFreqSelect');
+ const customInput = document.getElementById('airbandCustomFreq');
+ if (select.value === 'custom') {
+ customInput.style.display = 'inline-block';
+ } else {
+ customInput.style.display = 'none';
+ // If audio is playing, restart on new frequency
+ if (isAirbandPlaying) {
+ stopAirband();
+ setTimeout(() => startAirband(), 300);
+ }
+ }
+ }
+
+ // Handle custom frequency input changes
+ document.addEventListener('DOMContentLoaded', () => {
+ const customInput = document.getElementById('airbandCustomFreq');
+ if (customInput) {
+ customInput.addEventListener('change', () => {
+ // If audio is playing, restart on new custom frequency
+ if (isAirbandPlaying) {
+ stopAirband();
+ setTimeout(() => startAirband(), 300);
+ }
+ });
+ }
+ });
+
+ function getAirbandFrequency() {
+ const select = document.getElementById('airbandFreqSelect');
+ if (select.value === 'custom') {
+ return parseFloat(document.getElementById('airbandCustomFreq').value) || 121.5;
+ }
+ return parseFloat(select.value);
+ }
+
+ function toggleAirband() {
+ if (isAirbandPlaying) {
+ stopAirband();
+ } else {
+ startAirband();
+ }
+ }
+
+ function startAirband() {
+ const frequency = getAirbandFrequency();
+ const device = parseInt(document.getElementById('airbandDeviceSelect').value);
+ const squelch = parseInt(document.getElementById('airbandSquelch').value);
+
+ document.getElementById('airbandStatus').textContent = 'STARTING...';
+ document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
+
+ fetch('/spectrum/audio/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ frequency: frequency,
+ modulation: 'am', // Airband uses AM
+ squelch: squelch,
+ gain: 40,
+ device: device
+ })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'started') {
+ isAirbandPlaying = true;
+
+ // Start browser audio playback
+ const audioPlayer = document.getElementById('airbandPlayer');
+ audioPlayer.src = '/spectrum/audio/stream?' + Date.now();
+
+ // Initialize visualizer before playing
+ initAirbandVisualizer();
+
+ audioPlayer.play().catch(e => {
+ console.warn('Audio autoplay blocked:', e);
+ });
+
+ document.getElementById('airbandBtn').innerHTML = '⏹ STOP';
+ document.getElementById('airbandBtn').classList.add('active');
+ document.getElementById('airbandStatus').textContent = frequency.toFixed(3) + ' MHz';
+ document.getElementById('airbandStatus').style.color = 'var(--accent-green)';
+ } else {
+ document.getElementById('airbandStatus').textContent = 'ERROR';
+ document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
+ alert('Airband Error: ' + (data.message || 'Failed to start'));
+ }
+ })
+ .catch(err => {
+ document.getElementById('airbandStatus').textContent = 'ERROR';
+ document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
+ });
+ }
+
+ function stopAirband() {
+ // Stop visualizer
+ stopAirbandVisualizer();
+
+ // Stop browser audio
+ const audioPlayer = document.getElementById('airbandPlayer');
+ audioPlayer.pause();
+ audioPlayer.src = '';
+
+ fetch('/spectrum/audio/stop', { method: 'POST' })
+ .then(r => r.json())
+ .then(() => {
+ isAirbandPlaying = false;
+ document.getElementById('airbandBtn').innerHTML = '▶ LISTEN';
+ document.getElementById('airbandBtn').classList.remove('active');
+ document.getElementById('airbandStatus').textContent = 'OFF';
+ document.getElementById('airbandStatus').style.color = 'var(--text-muted)';
+ })
+ .catch(() => {});
+ }
+
+ // Initialize airband on page load
+ document.addEventListener('DOMContentLoaded', initAirband);